Merge gateway primitives into workspace backend

This commit is contained in:
matamune 2026-05-16 04:40:28 +00:00
parent c96668ec76
commit 94e4dfc61a
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
74 changed files with 2771 additions and 2048 deletions

View file

@ -14,5 +14,5 @@ Keep changes scoped to the bare package set:
- `packages/codex-client`
- `packages/ui`
Avoid reintroducing service, workspace, gateway, job, or host setup code on this
Avoid reintroducing service, workspace backend, job, or host setup code on this
branch.

View file

@ -4,12 +4,13 @@ Thin browser UI plus TypeScript client for `codex app-server`.
The current source is:
- `apps/web`: React/Vite UI that connects directly to a Codex app-server WebSocket.
- `apps/web`: React/Vite UI that connects through the workspace backend.
- `apps/cli`: Bun CLI that sends JSON-RPC actions to a listening Codex app-server.
- `apps/discord-bridge`: Discord sidecar that connects Discord threads to
Codex app-server threads and gateway delegation.
Codex app-server threads through the workspace backend capability model.
- `apps/flow-runner`: CLI for discovering and firing packaged flows.
- `apps/flow-backend-systemd-local`: local HTTP backend for executing flows from dispatch events.
- `apps/workspace-backend`: local workspace backend process with browser/control
WebSocket and optional flow HTTP routes.
- `docs`: Tome documentation site for codex-flow.
- `packages/codex-client`: JSON-RPC client, app-server transports, flow helpers, and generated protocol types.
- `packages/flow-runtime`: flow manifest loading, event matching, and runner primitives.
@ -23,10 +24,11 @@ Install dependencies:
bun install
```
Start a Codex app-server WebSocket in a separate shell:
Start the local workspace backend in a separate shell. It can spawn a local
stdio app-server:
```bash
codex app-server --listen ws://127.0.0.1:3585 --enable apps --enable hooks
bun run workspace:backend --local-app-server
```
Start the web app:
@ -36,15 +38,14 @@ bun run dev
```
In development, the web app defaults to a same-origin Vite WebSocket proxy at
`/__codex-app-server`, which forwards to `ws://127.0.0.1:3585`. This avoids
browser `Origin` header rejections from the app-server, which can show up in
WSL and other browser-to-localhost setups.
`/__codex-workspace-backend`, which forwards to `ws://127.0.0.1:3586`.
Set `VITE_CODEX_APP_SERVER_PROXY_TARGET` to proxy to a different app-server
URL. Set `VITE_CODEX_APP_SERVER_WS_URL` only when you explicitly want the
browser to connect directly to an app-server WebSocket.
Set `VITE_CODEX_WORKSPACE_BACKEND_PROXY_TARGET` to proxy to a different
workspace backend URL. Set `VITE_CODEX_WORKSPACE_BACKEND_WS_URL` only when you
explicitly want the browser to connect directly to a workspace backend
WebSocket.
Send a command to the running app-server:
Send a command to a standalone app-server WebSocket:
```bash
bun apps/cli/src/index.ts thread/list '{"limit": 20, "sourceKinds": []}'
@ -64,15 +65,15 @@ bun run build
bun run test
```
`bun run test` runs the client, flow runtime, local flow backend, CLI, and
Discord bridge tests.
`bun run test` runs the client, flow runtime, workspace backend, CLI, Discord
bridge, and web tests.
## Flow Automation
Flow packages live under `flows/*` and installed copies can live under
`.codex/flows/*`. The publishable Tome docs live in [docs](docs) and cover
`flow.toml`, generic `FlowEvent` dispatch, Bun and Code Mode runners, local
clients, systemd-local, and Convex backends.
clients, the workspace flow backend, and Convex backends.
```bash
bun run flow list
@ -172,6 +173,8 @@ The low-level app-server client package. It exports:
- `@peezy.tech/codex-flows/browser`: browser entry with WebSocket transport only.
- `@peezy.tech/codex-flows/flows`: framework-agnostic helpers for app servers that want to start Codex-backed workflows.
- `@peezy.tech/codex-flows/workbench`: transport-neutral thread UX state reducers and app-server request descriptors.
- `@peezy.tech/codex-flows/workspace-backend`: workspace backend client,
protocol server, and built-in capability primitives.
- `@peezy.tech/codex-flows/rpc`: JSON-RPC helpers and types.
- `@peezy.tech/codex-flows/generated`: generated Codex app-server protocol types.
@ -188,11 +191,14 @@ OpenCode Go upstream surface. See
CLI package for listing flow packages, firing every step that matches a
`FlowEvent`, or running one explicit flow step.
### `flow-backend-systemd-local`
### `codex-workspace-backend-local`
HTTP and CLI backend that persists dispatched flow events/runs to SQLite and
starts matching steps locally. It is intended to run as a small systemd-managed
service, with optional transient `systemd-run` units per step.
Local workspace backend process. In networked mode, it exposes the workspace
backend browser/control WebSocket and mounts the existing flow HTTP routes. The
same flow execution and inspection behavior is a built-in workspace capability
that embedded presenters can call directly without HTTP. Flow state is persisted
to SQLite, and matching steps can run directly or through transient
`systemd-run` units.
### `@peezy.tech/flow-runtime`
@ -221,9 +227,10 @@ and expose their own authenticated wrappers for service workers.
### `web`
The browser app imports `@peezy.tech/codex-flows/browser`, opens a direct WebSocket
connection, lists threads, starts turns, interrupts running turns, and renders
thread items and live app-server events.
The browser app imports `@peezy.tech/codex-flows/workspace-backend`, opens a
workspace backend WebSocket, lists threads, starts turns, interrupts running
turns, and renders thread items and live app-server events forwarded through the
workspace backend.
### `cli`

View file

@ -2,12 +2,12 @@
Long-lived Discord sidecar for connecting Discord to Codex app-server threads.
## Gateway Mode
## Workspace Mode
Gateway mode is opt-in. It keeps one Discord home surface as the primary UX, or
Workspace mode is opt-in. It keeps one Discord home surface as the primary UX, or
several guild-scoped surfaces when multi-guild routing is configured, and one
main Codex thread as the operator memory for the gateway. Legacy
thread-per-task behavior remains available outside the configured gateway
main Codex thread as the operator memory for the workspace. Legacy
thread-per-task behavior remains available outside the configured workspace
channels.
Set these environment values before starting the bridge:
@ -31,7 +31,7 @@ top-level workspace under it:
```toml
# /home/peezy/crypto-workspace/.codex/workspace.toml
[[discord.gateway.surfaces]]
[[discord.workspace.surfaces]]
key = "crypto"
home_channel_id = "1503107617512919220"
workspace_forum_channel_id = "1503107617512919221"
@ -44,20 +44,20 @@ workspace is the route. Workspaces without a Discord surface entry use the
default `.env` surface. If multiple workspaces name the same surface key with
the same channel ids, they are merged into one guild surface. Surface keys and
channel ids must be unique, and each workspace file may contain at most one
`[[discord.gateway.surfaces]]` entry.
`[[discord.workspace.surfaces]]` entry.
`CODEX_DISCORD_MAIN_THREAD_ID` is optional. If omitted, the bridge creates a new
main operator thread, attaches the privileged gateway tools to it, and stores it
main operator thread, attaches the privileged workspace tools to it, and stores it
in the bridge state file. Existing configured main threads are resumed as-is;
recreate the main operator thread if you need to attach gateway tools to a
thread that predates gateway mode.
recreate the main operator thread if you need to attach workspace tools to a
thread that predates workspace mode.
In each configured home channel:
- normal messages are sent to the main operator thread
- bot mentions are treated as gateway messages and do not create Discord task
- bot mentions are treated as workspace messages and do not create Discord task
threads
- `/status` replies directly with gateway state instead of starting a Codex turn
- `/status` replies directly with workspace state instead of starting a Codex turn
- `/status` also lists active Codex threads, linking any opened Discord thread
on the same surface and offering private buttons to open active threads that
are not yet in Discord
@ -66,14 +66,14 @@ In each configured home channel:
- `/goals` inside an opened Codex Discord thread manages that specific thread's
goal; use the slash options to set the objective/status/token budget or clear it
The prompt sent to the main thread uses `[discord-gateway]` framing so the model
knows it is operating as the gateway over the codex-flows backend, not as a
The prompt sent to the main thread uses `[discord-workspace]` framing so the model
knows it is operating as the workspace over the codex-flows backend, not as a
single task thread.
## Delegation Tools
Discord should not become a workspace registry. The main operator thread is the
place where routing decisions happen. Privileged `codex_gateway` dynamic tools
place where routing decisions happen. Privileged `codex_workspace` dynamic tools
are attached only to that main thread and expose:
- `list_delegations`
@ -98,15 +98,15 @@ Those tools can:
- record completed delegation results into the main operator thread
- inspect flow backend state through `CODEX_FLOW_BACKEND_URL`
Gateway state stores delegation records, including optional Discord detail
Workspace state stores delegation records, including optional Discord detail
thread ids for noisy work. Delegated Codex sessions do not receive the privileged
gateway tools; only the main operator thread can manage delegation.
workspace tools; only the main operator thread can manage delegation.
## Workbench Prototype
The gateway can optionally maintain a noisy Discord workbench beside each home
The workspace can optionally maintain a noisy Discord workbench beside each home
channel. Configure both channels on the default `.env` surface, or on a
workspace-owned `[[discord.gateway.surfaces]]` entry, to enable it:
workspace-owned `[[discord.workspace.surfaces]]` entry, to enable it:
```bash
CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID=1502107617512919221
@ -116,7 +116,7 @@ CODEX_DISCORD_TASK_THREADS_CHANNEL_ID=1502107617512919222
The home channel remains the compact operator chat. Each surface's workspace
forum gets one post for each discoverable top-level folder under
`CODEX_DISCORD_DIR` that routes to that surface. `CODEX_DISCORD_DIR` is the
gateway's main workspace root. For the home-folder gateway, set
workspace's main workspace root. For the home-folder workspace, set
`CODEX_DISCORD_DIR=/home/peezy`; a delegated cwd such as
`/home/peezy/codex-fork-workspace/codex-flows` maps to the
`/home/peezy/codex-fork-workspace` workspace post. Hidden folders and
@ -167,22 +167,22 @@ Delegations support return modes:
- `wake_on_done`: inject and mirror the result, then wake the main operator when idle
- `wake_on_group`: inject and mirror each result, then wake once the whole group is terminal
- `record_only`: inject and mirror results without waking the main operator
- `manual`: keep results in gateway state until `flush_delegation_results`
- `manual`: keep results in workspace state until `flush_delegation_results`
- `detached`: do not loop results back to the main thread; useful for human-continued threads
Automatic result return uses `thread/inject_items` to append structured
delegation results to the main operator thread's model-visible history. Codex
hooks, not background thread polling, drive automatic result return and passive
observability. The global hook writes durable lifecycle events into the spool
directory, and the gateway drains that spool on startup and while running.
directory, and the workspace drains that spool on startup and while running.
Starting a main-thread turn is a separate wake step, so long-running main goals
are not interrupted; wakes are queued until the main operator thread is idle.
For sessions that were not created through the gateway, the same hook stream
For sessions that were not created through the workspace, the same hook stream
updates an observed-thread index used by `/threads`.
## Codex Hooks
Install the global hooks once for the Codex runtime that backs the gateway:
Install the global hooks once for the Codex runtime that backs the workspace:
```bash
codex-discord-bridge hook install
@ -252,14 +252,14 @@ codex-discord-bridge hook install --bunx
codex-discord-bridge hook install --bunx-package @peezy.tech/codex-flows
```
The hook is intentionally dumb: it does not read gateway state or call the
The hook is intentionally dumb: it does not read workspace state or call the
backend. It only writes idempotent lifecycle-event files and lets Codex
continue. The gateway treats known delegated `Stop` events according to their
continue. The workspace treats known delegated `Stop` events according to their
return mode, uses main-operator `Stop` events to drain queued wakes, and records
unknown non-main sessions as observed threads. Observed threads are visible from
`/threads` for their workspace and can be opened into the task thread channel
on demand.
After changing hook configuration, restart the Codex runtime that backs the
gateway and trust the hook when Codex asks for review. `hooks/list` should show
workspace and trust the hook when Codex asks for review. `hooks/list` should show
the hook as `trusted`; untrusted hooks are discovered but do not run.

View file

@ -8,28 +8,28 @@ import type {
DiscordBridgeTransport,
} from "./types.ts";
import type {
CodexGatewayBackend,
CodexGatewayPresenter,
} from "./gateway-backend.ts";
CodexWorkspaceBackend,
CodexWorkspacePresenter,
} from "./workspace-backend.ts";
import {
LocalCodexGatewayBackend,
type LocalCodexGatewayBackendOptions,
LocalCodexWorkspaceBackend,
type LocalCodexWorkspaceBackendOptions,
parseThreadStartIntent,
splitDiscordMessage,
} from "./local-gateway-backend.ts";
} from "./local-workspace-backend.ts";
export { parseThreadStartIntent, splitDiscordMessage };
export { LocalCodexGatewayBackend };
export type { LocalCodexGatewayBackendOptions };
export { LocalCodexWorkspaceBackend };
export type { LocalCodexWorkspaceBackendOptions };
export type DiscordCodexBridgeLocalOptions =
Omit<LocalCodexGatewayBackendOptions, "presenter"> & {
Omit<LocalCodexWorkspaceBackendOptions, "presenter"> & {
transport: DiscordBridgeTransport;
backend?: undefined;
};
export type DiscordCodexBridgeBackendOptions = {
backend: CodexGatewayBackend;
backend: CodexWorkspaceBackend;
transport: DiscordBridgeTransport;
logger?: DiscordBridgeLogger;
config?: Pick<DiscordBridgeConfig, "debug" | "logLevel">;
@ -42,7 +42,7 @@ export type DiscordCodexBridgeOptions =
export class DiscordCodexBridge {
readonly transport: DiscordBridgeTransport;
readonly backend: CodexGatewayBackend;
readonly backend: CodexWorkspaceBackend;
#logger: DiscordBridgeLogger;
constructor(options: DiscordCodexBridgeOptions) {
@ -53,7 +53,7 @@ export class DiscordCodexBridge {
logLevel: options.config?.logLevel,
now: options.now,
});
this.backend = options.backend ?? new LocalCodexGatewayBackend({
this.backend = options.backend ?? new LocalCodexWorkspaceBackend({
...options,
presenter: discordTransportPresenter(options.transport),
});
@ -92,7 +92,7 @@ export class DiscordCodexBridge {
stateForTest(): DiscordBridgeState {
if (!this.backend.stateForTest) {
throw new Error("Gateway backend does not expose test state.");
throw new Error("Workspace backend does not expose test state.");
}
return this.backend.stateForTest();
}
@ -104,7 +104,7 @@ export class DiscordCodexBridge {
function discordTransportPresenter(
transport: DiscordBridgeTransport,
): CodexGatewayPresenter {
): CodexWorkspacePresenter {
return {
createWorkspacePost: transport.createForumPost?.bind(transport),
createThread: transport.createThread.bind(transport),

View file

@ -11,7 +11,7 @@ import type {
import type {
DiscordBridgeConfig,
DiscordConsoleOutputMode,
DiscordGatewaySurfaceConfig,
DiscordWorkspaceSurfaceConfig,
DiscordProgressMode,
} from "./types.ts";
import type { DiscordBridgeLogLevelSetting } from "./logger.ts";
@ -67,7 +67,7 @@ const sandboxValues = new Set<v2.SandboxMode>([
"workspace-write",
"danger-full-access",
]);
const defaultGatewaySurfaceKey = "default";
const defaultWorkspaceSurfaceKey = "default";
export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfig {
const args = parseFlags(argv);
@ -135,7 +135,7 @@ export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfi
env.CODEX_DISCORD_ALLOWED_CHANNEL_IDS,
),
statePath,
gateway: gatewayConfig(args, env, cwd),
workspace: workspaceConfig(args, env, cwd),
flowBackendUrl:
stringFlag(args, "flow-backend-url") ??
env.CODEX_FLOW_BACKEND_URL ??
@ -290,15 +290,15 @@ function optionalProgressMode(value: string | undefined): DiscordProgressMode |
return value as DiscordProgressMode;
}
function gatewayConfig(
function workspaceConfig(
flags: Map<string, string | boolean>,
env: NodeJS.ProcessEnv,
workspaceRoot: string | undefined,
): DiscordBridgeConfig["gateway"] {
const workspaceSurfaces = gatewaySurfacesConfig(workspaceRoot);
): DiscordBridgeConfig["workspace"] {
const workspaceSurfaces = workspaceSurfacesConfig(workspaceRoot);
const configuredHomeChannelId =
stringFlag(flags, "home-channel-id") ??
stringFlag(flags, "gateway-home-channel-id") ??
stringFlag(flags, "workspace-home-channel-id") ??
env.CODEX_DISCORD_HOME_CHANNEL_ID ??
env.CODEX_DISCORD_GATEWAY_HOME_CHANNEL_ID;
const useWorkspacePrimaryDefaults = !configuredHomeChannelId;
@ -306,12 +306,12 @@ function gatewayConfig(
workspaceSurfaces[0]?.homeChannelId;
const mainThreadId =
stringFlag(flags, "main-thread-id") ??
stringFlag(flags, "gateway-main-thread-id") ??
stringFlag(flags, "workspace-main-thread-id") ??
env.CODEX_DISCORD_MAIN_THREAD_ID ??
env.CODEX_DISCORD_GATEWAY_MAIN_THREAD_ID;
const configuredWorkspaceForumChannelId =
stringFlag(flags, "workspace-forum-channel-id") ??
stringFlag(flags, "gateway-workspace-forum-channel-id") ??
stringFlag(flags, "workspace-workspace-forum-channel-id") ??
env.CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID ??
env.CODEX_DISCORD_GATEWAY_WORKSPACE_FORUM_CHANNEL_ID;
const workspaceForumChannelId = configuredWorkspaceForumChannelId ??
@ -320,7 +320,7 @@ function gatewayConfig(
: undefined);
const configuredTaskThreadsChannelId =
stringFlag(flags, "task-threads-channel-id") ??
stringFlag(flags, "gateway-task-threads-channel-id") ??
stringFlag(flags, "workspace-task-threads-channel-id") ??
env.CODEX_DISCORD_TASK_THREADS_CHANNEL_ID ??
env.CODEX_DISCORD_GATEWAY_TASK_THREADS_CHANNEL_ID;
const taskThreadsChannelId = configuredTaskThreadsChannelId ??
@ -329,10 +329,10 @@ function gatewayConfig(
: undefined);
if (!homeChannelId) {
if (mainThreadId) {
throw new Error("Cannot set a gateway main thread without a gateway home channel.");
throw new Error("Cannot set a workspace main thread without a workspace home channel.");
}
if (workspaceForumChannelId || taskThreadsChannelId) {
throw new Error("Cannot set Discord workbench channels without a gateway home channel.");
throw new Error("Cannot set Discord workbench channels without a workspace home channel.");
}
return undefined;
}
@ -349,17 +349,17 @@ function gatewayConfig(
workspaceForumChannelId === taskThreadsChannelId)
) {
throw new Error(
"Discord workbench channels must be separate from the gateway home channel and each other.",
"Discord workbench channels must be separate from the workspace home channel and each other.",
);
}
const defaultSurface = {
key: defaultGatewaySurfaceKey,
key: defaultWorkspaceSurfaceKey,
homeChannelId,
workspaceForumChannelId,
taskThreadsChannelId,
};
const surfaces = workspaceSurfaces.length > 0
? mergeGatewaySurfaces(
? mergeWorkspaceSurfaces(
configuredHomeChannelId
? [defaultSurface, ...workspaceSurfaces]
: workspaceSurfaces,
@ -374,9 +374,9 @@ function gatewayConfig(
};
}
function gatewaySurfacesConfig(
function workspaceSurfacesConfig(
workspaceRoot: string | undefined,
): DiscordGatewaySurfaceConfig[] {
): DiscordWorkspaceSurfaceConfig[] {
if (!workspaceRoot) {
return [];
}
@ -384,14 +384,14 @@ function gatewaySurfacesConfig(
if (workspaceCwds.length === 0) {
return [];
}
const surfaces: DiscordGatewaySurfaceConfig[] = [];
const surfaces: DiscordWorkspaceSurfaceConfig[] = [];
for (const workspaceCwd of workspaceCwds) {
const surface = workspaceGatewaySurfaceConfig(workspaceCwd);
const surface = workspaceWorkspaceSurfaceConfig(workspaceCwd);
if (surface) {
surfaces.push(surface);
}
}
return mergeGatewaySurfaces(surfaces);
return mergeWorkspaceSurfaces(surfaces);
}
function discoverWorkspaceConfigCwds(workspaceRoot: string): string[] {
@ -434,9 +434,9 @@ function isDiscoverableWorkspaceEntry(name: string): boolean {
name !== "node_modules";
}
function workspaceGatewaySurfaceConfig(
function workspaceWorkspaceSurfaceConfig(
workspaceCwd: string,
): DiscordGatewaySurfaceConfig | undefined {
): DiscordWorkspaceSurfaceConfig | undefined {
const configPath = path.join(workspaceCwd, ".codex", "workspace.toml");
if (!existsSync(configPath)) {
return undefined;
@ -449,13 +449,13 @@ function workspaceGatewaySurfaceConfig(
`Invalid workspace config TOML at ${configPath}: ${errorMessage(error)}`,
);
}
const surfacesInput = gatewaySurfaceEntries(parsed);
const surfacesInput = workspaceSurfaceEntries(parsed);
if (surfacesInput === undefined) {
return undefined;
}
if (!Array.isArray(surfacesInput)) {
throw new Error(
`workspace.toml discord.gateway.surfaces must be an array: ${configPath}`,
`workspace.toml discord.workspace.surfaces must be an array: ${configPath}`,
);
}
if (surfacesInput.length === 0) {
@ -463,30 +463,30 @@ function workspaceGatewaySurfaceConfig(
}
if (surfacesInput.length > 1) {
throw new Error(
`workspace.toml discord.gateway.surfaces must contain one surface: ${configPath}`,
`workspace.toml discord.workspace.surfaces must contain one surface: ${configPath}`,
);
}
return parseGatewaySurface(surfacesInput[0], 0, workspaceCwd);
return parseWorkspaceSurface(surfacesInput[0], 0, workspaceCwd);
}
function gatewaySurfaceEntries(input: unknown): unknown {
function workspaceSurfaceEntries(input: unknown): unknown {
const parsed = record(input);
if (parsed.discord === undefined) {
return undefined;
}
const discord = record(parsed.discord);
if (discord.gateway === undefined) {
if (discord.workspace === undefined) {
return undefined;
}
const gateway = record(discord.gateway);
return gateway.surfaces;
const workspace = record(discord.workspace);
return workspace.surfaces;
}
function parseGatewaySurface(
function parseWorkspaceSurface(
input: unknown,
index: number,
workspaceCwd: string,
): DiscordGatewaySurfaceConfig {
): DiscordWorkspaceSurfaceConfig {
const parsed = record(input);
const key = optionalString(parsed.key) ?? optionalString(parsed.name);
const homeChannelId = optionalString(parsed.homeChannelId) ??
@ -496,14 +496,14 @@ function parseGatewaySurface(
const taskThreadsChannelId = optionalString(parsed.taskThreadsChannelId) ??
optionalString(parsed.task_threads_channel_id);
if (!key) {
throw new Error(`Gateway surface at index ${index} is missing key.`);
throw new Error(`Workspace surface at index ${index} is missing key.`);
}
if (!homeChannelId) {
throw new Error(`Gateway surface ${key} is missing homeChannelId.`);
throw new Error(`Workspace surface ${key} is missing homeChannelId.`);
}
if (Boolean(workspaceForumChannelId) !== Boolean(taskThreadsChannelId)) {
throw new Error(
`Gateway surface ${key} requires both workspaceForumChannelId and taskThreadsChannelId.`,
`Workspace surface ${key} requires both workspaceForumChannelId and taskThreadsChannelId.`,
);
}
if (
@ -513,7 +513,7 @@ function parseGatewaySurface(
homeChannelId === taskThreadsChannelId ||
workspaceForumChannelId === taskThreadsChannelId)
) {
throw new Error(`Gateway surface ${key} channels must be distinct.`);
throw new Error(`Workspace surface ${key} channels must be distinct.`);
}
return {
key,
@ -524,10 +524,10 @@ function parseGatewaySurface(
};
}
function mergeGatewaySurfaces(
surfaces: DiscordGatewaySurfaceConfig[],
): DiscordGatewaySurfaceConfig[] {
const byKey = new Map<string, DiscordGatewaySurfaceConfig>();
function mergeWorkspaceSurfaces(
surfaces: DiscordWorkspaceSurfaceConfig[],
): DiscordWorkspaceSurfaceConfig[] {
const byKey = new Map<string, DiscordWorkspaceSurfaceConfig>();
for (const surface of surfaces) {
const existing = byKey.get(surface.key);
if (!existing) {
@ -545,7 +545,7 @@ function mergeGatewaySurfaces(
existing.taskThreadsChannelId !== surface.taskThreadsChannelId
) {
throw new Error(
`Gateway surface key ${surface.key} is configured with different channels.`,
`Workspace surface key ${surface.key} is configured with different channels.`,
);
}
existing.workspaceCwds = existing.workspaceCwds && surface.workspaceCwds
@ -556,11 +556,11 @@ function mergeGatewaySurfaces(
: undefined;
}
const merged = [...byKey.values()];
validateGatewaySurfaces(merged);
validateWorkspaceSurfaces(merged);
return merged;
}
function validateGatewaySurfaces(surfaces: DiscordGatewaySurfaceConfig[]): void {
function validateWorkspaceSurfaces(surfaces: DiscordWorkspaceSurfaceConfig[]): void {
const channelIds = new Set<string>();
let catchAllSurfaces = 0;
for (const surface of surfaces) {
@ -577,14 +577,14 @@ function validateGatewaySurfaces(surfaces: DiscordGatewaySurfaceConfig[]): void
}
if (channelIds.has(channelId)) {
throw new Error(
`Gateway surface channel is configured more than once: ${channelId}`,
`Workspace surface channel is configured more than once: ${channelId}`,
);
}
channelIds.add(channelId);
}
}
if (catchAllSurfaces > 1) {
throw new Error("Only one gateway surface may omit workspaceCwds.");
throw new Error("Only one workspace surface may omit workspaceCwds.");
}
}
@ -657,12 +657,12 @@ Options:
--local-app-server Start a local app-server over stdio
--state-path <path> Persistent bridge state file
--allowed-channel-ids <ids> Comma-separated parent channel ids
--home-channel-id <id> Enable gateway mode for one Discord home channel
--main-thread-id <id> Resume an existing Codex operator thread for gateway mode
--home-channel-id <id> Enable workspace mode for one Discord home channel
--main-thread-id <id> Resume an existing Codex operator thread for workspace mode
--workspace-forum-channel-id <id>
Optional workbench forum channel for workspace posts
--task-threads-channel-id <id> Optional workbench text channel for task threads
--flow-backend-url <url> Optional codex-flow-systemd-local backend URL
--flow-backend-url <url> Optional workspace flow HTTP backend URL
--hook-spool-dir <path> Directory drained for Codex hook events
[dir] Optional Codex thread directory, resolved from home
--dir <path> Codex thread directory, resolved from home

View file

@ -732,7 +732,7 @@ function discordBridgeCommands(): ApplicationCommandDataResolvable[] {
},
{
name: "status",
description: "Show Codex gateway status",
description: "Show Codex workspace status",
},
{
name: "threads",

View file

@ -6,7 +6,7 @@ import { writeHookSpoolEvent } from "./stop-hook-spool.ts";
const defaultHookCommand = "codex-discord-bridge hook event";
const defaultBunxPackage = "codex-discord-bridge";
const gatewayHookEvents = [
const workspaceHookEvents = [
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
@ -62,7 +62,7 @@ export async function runHookEvent(): Promise<void> {
process.stdout.write(`${JSON.stringify({ continue: true })}\n`);
}
} catch (error) {
process.stderr.write(`discord gateway hook failed: ${errorMessage(error)}\n`);
process.stderr.write(`discord workspace hook failed: ${errorMessage(error)}\n`);
if (eventSupportsContinueOutput(eventNameFromHookInput(input))) {
process.stdout.write(`${JSON.stringify({ continue: true })}\n`);
}
@ -125,12 +125,12 @@ export function upsertStopHookConfig(
): Record<string, unknown> {
const config = parseHooksJson(hooksText);
const hooks = record(config.hooks);
for (const eventName of gatewayHookEvents) {
for (const eventName of workspaceHookEvents) {
const groups = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
hooks[eventName] = [
hookGroup(command),
...groups
.map(removeGatewayStopHookHandlers)
.map(removeWorkspaceStopHookHandlers)
.filter((group): group is Record<string, unknown> => group !== undefined),
];
}
@ -217,10 +217,10 @@ function hookGroup(command: string): Record<string, unknown> {
};
}
function removeGatewayStopHookHandlers(input: unknown): Record<string, unknown> | undefined {
function removeWorkspaceStopHookHandlers(input: unknown): Record<string, unknown> | undefined {
const group = record(input);
const handlers = Array.isArray(group.hooks)
? group.hooks.filter((handler) => !isGatewayStopHookHandler(handler))
? group.hooks.filter((handler) => !isWorkspaceStopHookHandler(handler))
: [];
if (handlers.length === 0) {
return undefined;
@ -228,12 +228,12 @@ function removeGatewayStopHookHandlers(input: unknown): Record<string, unknown>
return { ...group, hooks: handlers };
}
function isGatewayStopHookHandler(input: unknown): boolean {
function isWorkspaceStopHookHandler(input: unknown): boolean {
const handler = record(input);
const command = typeof handler.command === "string" ? handler.command : "";
return command.includes("codex-discord-bridge hook stop") ||
command.includes("codex-discord-bridge hook event") ||
command.includes("codex-discord-gateway-stop-hook") ||
command.includes("codex-discord-workspace-stop-hook") ||
command.includes("apps/discord-bridge/src/stop-hook.ts");
}

View file

@ -1,6 +1,6 @@
import type { JsonRpcNotification } from "@peezy.tech/codex-flows/rpc";
import type { v2 } from "@peezy.tech/codex-flows/generated";
import type { CodexGatewayPresenter } from "./gateway-backend.ts";
import type { CodexWorkspacePresenter } from "./workspace-backend.ts";
import type {
DiscordConsoleMessageKind,
@ -25,7 +25,7 @@ const runningCommandStatusDelayMs = 5_000;
export type ThreadRunnerContext = {
client: CodexBridgeClient;
presenter: CodexGatewayPresenter;
presenter: CodexWorkspacePresenter;
config: DiscordBridgeConfig;
getState(): DiscordBridgeState;
persist(): Promise<void>;
@ -999,7 +999,7 @@ export class DiscordThreadRunner {
this.#state().deliveries
.filter(
(delivery) =>
(this.session.mode === "gateway" ||
(this.session.mode === "operator" ||
delivery.discordThreadId === this.session.discordThreadId) &&
delivery.codexThreadId === this.session.codexThreadId &&
delivery.kind === "final" &&
@ -1565,7 +1565,7 @@ export class DiscordThreadRunner {
#hasDelivery(turnId: string, kind: DiscordBridgeDelivery["kind"]): boolean {
return this.#state().deliveries.some(
(delivery) =>
(this.session.mode === "gateway" ||
(this.session.mode === "operator" ||
delivery.discordThreadId === this.session.discordThreadId) &&
delivery.codexThreadId === this.session.codexThreadId &&
delivery.turnId === turnId &&
@ -1603,7 +1603,7 @@ export class DiscordThreadRunner {
#sessionActiveTurns(): DiscordBridgeActiveTurn[] {
return this.#state().activeTurns.filter(
(active) =>
(this.session.mode === "gateway" ||
(this.session.mode === "operator" ||
active.discordThreadId === this.session.discordThreadId) &&
active.codexThreadId === this.session.codexThreadId,
);
@ -1620,7 +1620,7 @@ export class DiscordThreadRunner {
const observedAt = this.#context.now().toISOString();
state.activeTurns = state.activeTurns.filter(
(active) =>
(this.session.mode !== "gateway" &&
(this.session.mode !== "operator" &&
active.discordThreadId !== this.session.discordThreadId) ||
active.codexThreadId !== this.session.codexThreadId ||
active.turnId === input.turnId,
@ -1651,7 +1651,7 @@ export class DiscordThreadRunner {
const state = this.#state();
state.activeTurns = state.activeTurns.filter(
(active) =>
(this.session.mode !== "gateway" &&
(this.session.mode !== "operator" &&
active.discordThreadId !== this.session.discordThreadId) ||
active.codexThreadId !== this.session.codexThreadId ||
active.turnId !== turnId,
@ -1677,7 +1677,7 @@ export class DiscordThreadRunner {
#sessionQueueItems(): DiscordBridgeQueueItem[] {
return this.#state().queue.filter(
(item) =>
(this.session.mode === "gateway" ||
(this.session.mode === "operator" ||
item.discordThreadId === this.session.discordThreadId) &&
item.codexThreadId === this.session.codexThreadId,
);
@ -2079,20 +2079,20 @@ function formatDiscordPrompt(
session: DiscordBridgeSession,
config: DiscordBridgeConfig,
): string {
if (session.mode === "gateway") {
const surface = config.gateway?.surfaces?.find((candidate) =>
if (session.mode === "operator") {
const surface = config.workspace?.surfaces?.find((candidate) =>
candidate.homeChannelId === item.discordThreadId
);
return [
"[discord-gateway]",
"Role: You are the main Codex operator thread for a configured Discord gateway surface.",
"Intent: Treat this as a gateway request. Answer directly when appropriate; otherwise reason about backend/runtime delegation without assuming Discord itself owns a workspace registry.",
"[discord-workspace]",
"Role: You are the main Codex operator thread for a configured Discord workspace surface.",
"Intent: Treat this as a workspace request. Answer directly when appropriate; otherwise reason about backend/runtime delegation without assuming Discord itself owns a workspace registry.",
"Canonical memory: This main Codex thread is the operator memory. Delegated Codex threads remain canonical history for delegated work.",
surface ? `Surface: ${surface.key}` : undefined,
`Author: ${item.authorName} (${item.authorId})`,
`Message: ${item.discordMessageId}`,
`Home channel: ${item.discordThreadId}`,
`Gateway cwd: ${session.cwd ?? "default"}`,
`Workspace cwd: ${session.cwd ?? "default"}`,
"",
item.content,
].filter((line): line is string => line !== undefined).join("\n");

View file

@ -9,11 +9,11 @@ import type {
DiscordBridgeSession,
DiscordBridgeState,
DiscordBridgeStateStore,
DiscordGatewayDelegation,
DiscordGatewayHookEventName,
DiscordGatewayObservedThread,
DiscordGatewayWorkspaceSurface,
DiscordGatewayState,
DiscordWorkspaceDelegation,
DiscordWorkspaceHookEventName,
DiscordWorkspaceObservedThread,
DiscordWorkspaceWorkspaceSurface,
DiscordWorkspaceState,
} from "./types.ts";
const maxProcessedMessageIds = 1000;
@ -66,7 +66,7 @@ export class MemoryStateStore implements DiscordBridgeStateStore {
export function emptyState(): DiscordBridgeState {
return {
version: 1,
gateway: undefined,
workspace: undefined,
sessions: [],
queue: [],
activeTurns: [],
@ -80,18 +80,18 @@ export function trimState(state: DiscordBridgeState): void {
-maxProcessedMessageIds,
);
state.deliveries = state.deliveries.slice(-maxDeliveries);
if (state.gateway?.processedStopHookEventIds) {
state.gateway.processedStopHookEventIds =
state.gateway.processedStopHookEventIds.slice(
if (state.workspace?.processedStopHookEventIds) {
state.workspace.processedStopHookEventIds =
state.workspace.processedStopHookEventIds.slice(
-maxProcessedStopHookEventIds,
);
}
if (state.gateway?.processedHookEventIds) {
state.gateway.processedHookEventIds =
state.gateway.processedHookEventIds.slice(-maxProcessedHookEventIds);
if (state.workspace?.processedHookEventIds) {
state.workspace.processedHookEventIds =
state.workspace.processedHookEventIds.slice(-maxProcessedHookEventIds);
}
if (state.gateway?.observedThreads) {
state.gateway.observedThreads = [...state.gateway.observedThreads]
if (state.workspace?.observedThreads) {
state.workspace.observedThreads = [...state.workspace.observedThreads]
.sort((left, right) =>
Date.parse(right.lastSeenAt) - Date.parse(left.lastSeenAt)
)
@ -105,7 +105,7 @@ function parseState(value: unknown): DiscordBridgeState {
}
return {
version: 1,
gateway: parseGateway(value.gateway),
workspace: parseWorkspace(value.workspace),
sessions: Array.isArray(value.sessions)
? value.sessions.map(parseSession)
: [],
@ -124,30 +124,30 @@ function parseState(value: unknown): DiscordBridgeState {
};
}
function parseGateway(value: unknown): DiscordGatewayState | undefined {
function parseWorkspace(value: unknown): DiscordWorkspaceState | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge gateway state");
throw new Error("Invalid Discord bridge workspace state");
}
return {
homeChannelId: requiredString(value.homeChannelId, "gateway.homeChannelId"),
homeChannelId: requiredString(value.homeChannelId, "workspace.homeChannelId"),
mainThreadId: optionalString(value.mainThreadId),
statusMessageId: optionalString(value.statusMessageId),
createdAt: optionalString(value.createdAt),
toolsVersion: optionalNumber(value.toolsVersion),
delegations: Array.isArray(value.delegations)
? value.delegations.map(parseGatewayDelegation)
? value.delegations.map(parseWorkspaceDelegation)
: [],
workspaces: Array.isArray(value.workspaces)
? value.workspaces.map(parseGatewayWorkspace)
? value.workspaces.map(parseWorkspaceWorkspace)
: [],
observedThreads: Array.isArray(value.observedThreads)
? value.observedThreads.map(parseGatewayObservedThread)
? value.observedThreads.map(parseWorkspaceObservedThread)
: [],
pendingWakes: Array.isArray(value.pendingWakes)
? value.pendingWakes.map(parseGatewayPendingWake)
? value.pendingWakes.map(parseWorkspacePendingWake)
: [],
processedHookEventIds: uniqueStrings([
...(Array.isArray(value.processedHookEventIds)
@ -163,9 +163,9 @@ function parseGateway(value: unknown): DiscordGatewayState | undefined {
};
}
function parseGatewayDelegation(value: unknown): DiscordGatewayDelegation {
function parseWorkspaceDelegation(value: unknown): DiscordWorkspaceDelegation {
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge gateway delegation");
throw new Error("Invalid Discord bridge workspace delegation");
}
const status = value.status;
if (
@ -175,15 +175,15 @@ function parseGatewayDelegation(value: unknown): DiscordGatewayDelegation {
status !== "complete" &&
status !== "reported"
) {
throw new Error("Invalid Discord bridge gateway delegation status");
throw new Error("Invalid Discord bridge workspace delegation status");
}
return {
id: requiredString(value.id, "gateway.delegations.id"),
id: requiredString(value.id, "workspace.delegations.id"),
codexThreadId: requiredString(
value.codexThreadId,
"gateway.delegations.codexThreadId",
"workspace.delegations.codexThreadId",
),
title: requiredString(value.title, "gateway.delegations.title"),
title: requiredString(value.title, "workspace.delegations.title"),
status,
cwd: optionalString(value.cwd),
workspaceKey: optionalString(value.workspaceKey),
@ -202,64 +202,64 @@ function parseGatewayDelegation(value: unknown): DiscordGatewayDelegation {
mirroredAt: optionalString(value.mirroredAt),
taskMirroredAt: optionalString(value.taskMirroredAt),
reportedAt: optionalString(value.reportedAt),
createdAt: requiredString(value.createdAt, "gateway.delegations.createdAt"),
updatedAt: requiredString(value.updatedAt, "gateway.delegations.updatedAt"),
createdAt: requiredString(value.createdAt, "workspace.delegations.createdAt"),
updatedAt: requiredString(value.updatedAt, "workspace.delegations.updatedAt"),
};
}
function parseGatewayWorkspace(value: unknown): DiscordGatewayWorkspaceSurface {
function parseWorkspaceWorkspace(value: unknown): DiscordWorkspaceWorkspaceSurface {
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge gateway workspace");
throw new Error("Invalid Discord bridge workspace workspace");
}
return {
key: requiredString(value.key, "gateway.workspaces.key"),
key: requiredString(value.key, "workspace.workspaces.key"),
surfaceKey: optionalString(value.surfaceKey),
cwd: requiredString(value.cwd, "gateway.workspaces.cwd"),
title: requiredString(value.title, "gateway.workspaces.title"),
cwd: requiredString(value.cwd, "workspace.workspaces.cwd"),
title: requiredString(value.title, "workspace.workspaces.title"),
discordThreadId: requiredString(
value.discordThreadId,
"gateway.workspaces.discordThreadId",
"workspace.workspaces.discordThreadId",
),
statusMessageId: optionalString(value.statusMessageId),
delegationIds: Array.isArray(value.delegationIds)
? uniqueStrings(value.delegationIds)
: [],
createdAt: requiredString(value.createdAt, "gateway.workspaces.createdAt"),
updatedAt: requiredString(value.updatedAt, "gateway.workspaces.updatedAt"),
createdAt: requiredString(value.createdAt, "workspace.workspaces.createdAt"),
updatedAt: requiredString(value.updatedAt, "workspace.workspaces.updatedAt"),
};
}
function parseGatewayPendingWake(
function parseWorkspacePendingWake(
value: unknown,
): NonNullable<DiscordGatewayState["pendingWakes"]>[number] {
): NonNullable<DiscordWorkspaceState["pendingWakes"]>[number] {
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge gateway pending wake");
throw new Error("Invalid Discord bridge workspace pending wake");
}
const kind = value.kind === "delegation" || value.kind === "group"
? value.kind
: undefined;
if (!kind) {
throw new Error("Invalid Discord bridge gateway pending wake kind");
throw new Error("Invalid Discord bridge workspace pending wake kind");
}
return {
id: requiredString(value.id, "gateway.pendingWakes.id"),
id: requiredString(value.id, "workspace.pendingWakes.id"),
kind,
delegationIds: Array.isArray(value.delegationIds)
? uniqueStrings(value.delegationIds)
: [],
groupId: optionalString(value.groupId),
reason: requiredString(value.reason, "gateway.pendingWakes.reason"),
createdAt: requiredString(value.createdAt, "gateway.pendingWakes.createdAt"),
reason: requiredString(value.reason, "workspace.pendingWakes.reason"),
createdAt: requiredString(value.createdAt, "workspace.pendingWakes.createdAt"),
startedAt: optionalString(value.startedAt),
};
}
function parseGatewayObservedThread(value: unknown): DiscordGatewayObservedThread {
function parseWorkspaceObservedThread(value: unknown): DiscordWorkspaceObservedThread {
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge gateway observed thread");
throw new Error("Invalid Discord bridge workspace observed thread");
}
return {
threadId: requiredString(value.threadId, "gateway.observedThreads.threadId"),
threadId: requiredString(value.threadId, "workspace.observedThreads.threadId"),
title: optionalString(value.title),
status: parseObservedThreadStatus(value.status),
cwd: optionalString(value.cwd),
@ -277,15 +277,15 @@ function parseGatewayObservedThread(value: unknown): DiscordGatewayObservedThrea
toolInputPreview: optionalString(value.toolInputPreview),
toolResponsePreview: optionalString(value.toolResponsePreview),
permissionDescription: optionalString(value.permissionDescription),
firstSeenAt: requiredString(value.firstSeenAt, "gateway.observedThreads.firstSeenAt"),
lastSeenAt: requiredString(value.lastSeenAt, "gateway.observedThreads.lastSeenAt"),
updatedAt: requiredString(value.updatedAt, "gateway.observedThreads.updatedAt"),
firstSeenAt: requiredString(value.firstSeenAt, "workspace.observedThreads.firstSeenAt"),
lastSeenAt: requiredString(value.lastSeenAt, "workspace.observedThreads.lastSeenAt"),
updatedAt: requiredString(value.updatedAt, "workspace.observedThreads.updatedAt"),
};
}
function parseObservedThreadStatus(
value: unknown,
): DiscordGatewayObservedThread["status"] {
): DiscordWorkspaceObservedThread["status"] {
return value === "starting" ||
value === "active" ||
value === "tool" ||
@ -295,7 +295,7 @@ function parseObservedThreadStatus(
: "idle";
}
function parseHookEventName(value: unknown): DiscordGatewayHookEventName | undefined {
function parseHookEventName(value: unknown): DiscordWorkspaceHookEventName | undefined {
return value === "SessionStart" ||
value === "UserPromptSubmit" ||
value === "PreToolUse" ||
@ -306,7 +306,7 @@ function parseHookEventName(value: unknown): DiscordGatewayHookEventName | undef
: undefined;
}
function parseReturnMode(value: unknown): DiscordGatewayDelegation["returnMode"] {
function parseReturnMode(value: unknown): DiscordWorkspaceDelegation["returnMode"] {
return value === "detached" ||
value === "record_only" ||
value === "wake_on_done" ||
@ -430,7 +430,7 @@ function optionalNumber(value: unknown): number | undefined {
function parseSessionMode(value: unknown): DiscordBridgeSession["mode"] {
return value === "new" ||
value === "resumed" ||
value === "gateway" ||
value === "workspace" ||
value === "delegated" ||
value === "workspace"
? value

View file

@ -11,8 +11,8 @@ import os from "node:os";
import path from "node:path";
import type {
DiscordGatewayHookEvent,
DiscordGatewayHookEventName,
DiscordWorkspaceHookEvent,
DiscordWorkspaceHookEventName,
} from "./types.ts";
export type HookEventSpoolDisposition = "processed" | "ignored" | "failed";
@ -22,7 +22,7 @@ export type PendingHookEventSpoolFile =
| {
filePath: string;
fileName: string;
event: DiscordGatewayHookEvent;
event: DiscordWorkspaceHookEvent;
}
| {
filePath: string;
@ -65,7 +65,7 @@ export async function writeStopHookSpoolEvent(
spoolDir?: string;
now?: () => Date;
} = {},
): Promise<DiscordGatewayHookEvent> {
): Promise<DiscordWorkspaceHookEvent> {
return await writeHookSpoolEvent(input, options);
}
@ -75,7 +75,7 @@ export async function writeHookSpoolEvent(
spoolDir?: string;
now?: () => Date;
} = {},
): Promise<DiscordGatewayHookEvent> {
): Promise<DiscordWorkspaceHookEvent> {
const spoolDir = options.spoolDir ?? stopHookSpoolDirFromEnv();
const event = hookEventFromInput(input, options.now ?? (() => new Date()));
const paths = stopHookSpoolPaths(spoolDir);
@ -151,7 +151,7 @@ export async function removeStopHookSpool(spoolDir: string): Promise<void> {
function hookEventFromInput(
input: unknown,
now: () => Date,
): DiscordGatewayHookEvent {
): DiscordWorkspaceHookEvent {
const parsed = record(input);
const eventName = stringValue(parsed.hook_event_name) ?? stringValue(parsed.eventName);
if (!isHookEventName(eventName)) {
@ -212,7 +212,7 @@ function hookEventFromInput(
};
}
function parseHookSpoolEvent(input: unknown): DiscordGatewayHookEvent {
function parseHookSpoolEvent(input: unknown): DiscordWorkspaceHookEvent {
const parsed = record(input);
if (parsed.version !== 1) {
throw new Error("Invalid hook event version");
@ -249,7 +249,7 @@ function parseHookSpoolEvent(input: unknown): DiscordGatewayHookEvent {
}
function hookEventId(input: {
eventName: DiscordGatewayHookEventName;
eventName: DiscordWorkspaceHookEventName;
sessionId: string;
turnId?: string;
transcriptPath?: string;
@ -277,7 +277,7 @@ function hookEventId(input: {
return `${prefix}-${createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 24)}`;
}
function isHookEventName(value: unknown): value is DiscordGatewayHookEventName {
function isHookEventName(value: unknown): value is DiscordWorkspaceHookEventName {
return value === "SessionStart" ||
value === "UserPromptSubmit" ||
value === "PreToolUse" ||

View file

@ -10,7 +10,7 @@ export type DiscordBridgeConfig = {
allowedUserIds: Set<string>;
allowedChannelIds: Set<string>;
statePath: string;
gateway?: DiscordGatewayConfig;
workspace?: DiscordWorkspaceConfig;
flowBackendUrl?: string;
cwd?: string;
model?: string;
@ -33,15 +33,15 @@ export type DiscordBridgeConfig = {
export type DiscordProgressMode = "summary" | "commentary" | "none";
export type DiscordConsoleOutputMode = "messages" | "none";
export type DiscordGatewayConfig = {
export type DiscordWorkspaceConfig = {
homeChannelId: string;
mainThreadId?: string;
workspaceForumChannelId?: string;
taskThreadsChannelId?: string;
surfaces?: DiscordGatewaySurfaceConfig[];
surfaces?: DiscordWorkspaceSurfaceConfig[];
};
export type DiscordGatewaySurfaceConfig = {
export type DiscordWorkspaceSurfaceConfig = {
key: string;
homeChannelId: string;
workspaceForumChannelId?: string;
@ -238,7 +238,7 @@ export type CodexBridgeClient = {
export type DiscordBridgeState = {
version: 1;
gateway?: DiscordGatewayState;
workspace?: DiscordWorkspaceState;
sessions: DiscordBridgeSession[];
queue: DiscordBridgeQueueItem[];
activeTurns: DiscordBridgeActiveTurn[];
@ -246,28 +246,28 @@ export type DiscordBridgeState = {
deliveries: DiscordBridgeDelivery[];
};
export type DiscordGatewayState = {
export type DiscordWorkspaceState = {
homeChannelId: string;
mainThreadId?: string;
statusMessageId?: string;
createdAt?: string;
toolsVersion?: number;
delegations: DiscordGatewayDelegation[];
workspaces?: DiscordGatewayWorkspaceSurface[];
observedThreads?: DiscordGatewayObservedThread[];
pendingWakes?: DiscordGatewayPendingWake[];
delegations: DiscordWorkspaceDelegation[];
workspaces?: DiscordWorkspaceWorkspaceSurface[];
observedThreads?: DiscordWorkspaceObservedThread[];
pendingWakes?: DiscordWorkspacePendingWake[];
processedHookEventIds?: string[];
processedStopHookEventIds?: string[];
};
export type DiscordGatewayDelegationReturnMode =
export type DiscordWorkspaceDelegationReturnMode =
| "detached"
| "record_only"
| "wake_on_done"
| "wake_on_group"
| "manual";
export type DiscordGatewayDelegation = {
export type DiscordWorkspaceDelegation = {
id: string;
codexThreadId: string;
title: string;
@ -276,7 +276,7 @@ export type DiscordGatewayDelegation = {
workspaceKey?: string;
surfaceKey?: string;
groupId?: string;
returnMode?: DiscordGatewayDelegationReturnMode;
returnMode?: DiscordWorkspaceDelegationReturnMode;
discordDetailThreadId?: string;
discordTaskThreadId?: string;
discordWorkspaceThreadId?: string;
@ -293,7 +293,7 @@ export type DiscordGatewayDelegation = {
updatedAt: string;
};
export type DiscordGatewayWorkspaceSurface = {
export type DiscordWorkspaceWorkspaceSurface = {
key: string;
surfaceKey?: string;
cwd: string;
@ -305,7 +305,7 @@ export type DiscordGatewayWorkspaceSurface = {
updatedAt: string;
};
export type DiscordGatewayPendingWake = {
export type DiscordWorkspacePendingWake = {
id: string;
kind: "delegation" | "group";
delegationIds: string[];
@ -315,7 +315,7 @@ export type DiscordGatewayPendingWake = {
startedAt?: string;
};
export type DiscordGatewayHookEventName =
export type DiscordWorkspaceHookEventName =
| "SessionStart"
| "UserPromptSubmit"
| "PreToolUse"
@ -323,10 +323,10 @@ export type DiscordGatewayHookEventName =
| "PostToolUse"
| "Stop";
export type DiscordGatewayHookEvent = {
export type DiscordWorkspaceHookEvent = {
version: 1;
id: string;
eventName: DiscordGatewayHookEventName;
eventName: DiscordWorkspaceHookEventName;
sessionId: string;
turnId?: string;
cwd?: string;
@ -344,28 +344,28 @@ export type DiscordGatewayHookEvent = {
createdAt: string;
};
export type DiscordGatewayStopHookEvent = DiscordGatewayHookEvent & {
export type DiscordWorkspaceStopHookEvent = DiscordWorkspaceHookEvent & {
eventName: "Stop";
};
export type DiscordGatewayObservedThreadStatus =
export type DiscordWorkspaceObservedThreadStatus =
| "starting"
| "active"
| "tool"
| "waiting"
| "idle";
export type DiscordGatewayObservedThread = {
export type DiscordWorkspaceObservedThread = {
threadId: string;
title?: string;
status: DiscordGatewayObservedThreadStatus;
status: DiscordWorkspaceObservedThreadStatus;
cwd?: string;
workspaceKey?: string;
surfaceKey?: string;
model?: string;
transcriptPath?: string;
lastTurnId?: string;
lastHookEventName?: DiscordGatewayHookEventName;
lastHookEventName?: DiscordWorkspaceHookEventName;
source?: string;
promptPreview?: string;
assistantPreview?: string;
@ -391,7 +391,7 @@ export type DiscordBridgeSession = {
ownerUserId?: string;
participantUserIds?: string[];
cwd?: string;
mode?: "new" | "resumed" | "gateway" | "delegated" | "workspace";
mode?: "new" | "resumed" | "operator" | "delegated" | "workspace";
statusMessageId?: string;
};

View file

@ -4,7 +4,7 @@ import type {
DiscordInbound,
} from "./types.ts";
export type CodexGatewayBackend = {
export type CodexWorkspaceBackend = {
start(): Promise<void>;
startTransportDependentWork?(): Promise<void>;
startBackgroundWork?(): Promise<void>;
@ -15,7 +15,7 @@ export type CodexGatewayBackend = {
flushSummariesForTest?(): Promise<void>;
};
export type CodexGatewayPresenter = {
export type CodexWorkspacePresenter = {
createWorkspacePost?(
locationId: string,
title: string,

File diff suppressed because it is too large Load diff

View file

@ -186,7 +186,7 @@ describe("parseConfig", () => {
}
});
test("parses gateway home and main thread ids", () => {
test("parses workspace home and main thread ids", () => {
const fromFlag = parseConfig(
[
"--token",
@ -220,14 +220,14 @@ describe("parseConfig", () => {
expect(fromFlag.type).toBe("run");
expect(fromEnv.type).toBe("run");
if (fromFlag.type === "run" && fromEnv.type === "run") {
expect(fromFlag.config.gateway).toEqual({
expect(fromFlag.config.workspace).toEqual({
homeChannelId: "home-channel",
mainThreadId: "main-thread",
workspaceForumChannelId: "workspace-forum",
taskThreadsChannelId: "task-channel",
});
expect(fromFlag.config.flowBackendUrl).toBe("http://127.0.0.1:8089");
expect(fromEnv.config.gateway).toEqual({
expect(fromEnv.config.workspace).toEqual({
homeChannelId: "env-home",
mainThreadId: "env-thread",
workspaceForumChannelId: "env-workspace-forum",
@ -237,17 +237,17 @@ describe("parseConfig", () => {
}
});
test("parses workspace-owned gateway surfaces and keeps env defaults as fallback", () => {
test("parses workspace-owned workspace surfaces and keeps env defaults as fallback", () => {
const root = workspaceRoot();
writeWorkspaceToml(root, "crypto-workspace", `
[[discord.gateway.surfaces]]
[[discord.workspace.surfaces]]
key = "crypto"
home_channel_id = "home-b"
workspace_forum_channel_id = "forum-b"
task_threads_channel_id = "tasks-b"
`);
writeWorkspaceToml(root, "research-workspace", `
[[discord.gateway.surfaces]]
[[discord.workspace.surfaces]]
key = "crypto"
home_channel_id = "home-b"
workspace_forum_channel_id = "forum-b"
@ -272,7 +272,7 @@ task_threads_channel_id = "tasks-b"
expect(parsed.type).toBe("run");
if (parsed.type === "run") {
expect(parsed.config.gateway).toEqual({
expect(parsed.config.workspace).toEqual({
homeChannelId: "home-a",
workspaceForumChannelId: "forum-a",
taskThreadsChannelId: "tasks-a",
@ -302,20 +302,20 @@ task_threads_channel_id = "tasks-b"
}
});
test("rejects ambiguous workspace-owned gateway surfaces", () => {
test("rejects ambiguous workspace-owned workspace surfaces", () => {
const multiple = workspaceRoot();
writeWorkspaceToml(multiple, "crypto-workspace", `
[[discord.gateway.surfaces]]
[[discord.workspace.surfaces]]
key = "default"
home_channel_id = "home-a"
[[discord.gateway.surfaces]]
[[discord.workspace.surfaces]]
key = "other"
home_channel_id = "home-b"
`);
try {
expect(() => parseConfig(baseArgsForRoot(multiple), {})).toThrow(
"workspace.toml discord.gateway.surfaces must contain one surface",
"workspace.toml discord.workspace.surfaces must contain one surface",
);
} finally {
rmSync(multiple, { recursive: true, force: true });
@ -323,18 +323,18 @@ home_channel_id = "home-b"
const duplicate = workspaceRoot();
writeWorkspaceToml(duplicate, "crypto-workspace", `
[[discord.gateway.surfaces]]
[[discord.workspace.surfaces]]
key = "default"
home_channel_id = "home-a"
`);
writeWorkspaceToml(duplicate, "research-workspace", `
[[discord.gateway.surfaces]]
[[discord.workspace.surfaces]]
key = "default"
home_channel_id = "home-b"
`);
try {
expect(() => parseConfig(baseArgsForRoot(duplicate), {})).toThrow(
"Gateway surface key default is configured with different channels.",
"Workspace surface key default is configured with different channels.",
);
} finally {
rmSync(duplicate, { recursive: true, force: true });
@ -342,25 +342,25 @@ home_channel_id = "home-b"
const channelCollision = workspaceRoot();
writeWorkspaceToml(channelCollision, "crypto-workspace", `
[[discord.gateway.surfaces]]
[[discord.workspace.surfaces]]
key = "crypto"
home_channel_id = "home-a"
`);
writeWorkspaceToml(channelCollision, "alpha-workspace", `
[[discord.gateway.surfaces]]
[[discord.workspace.surfaces]]
key = "alpha"
home_channel_id = "home-a"
`);
try {
expect(() => parseConfig(baseArgsForRoot(channelCollision), {})).toThrow(
"Gateway surface channel is configured more than once: home-a",
"Workspace surface channel is configured more than once: home-a",
);
} finally {
rmSync(channelCollision, { recursive: true, force: true });
}
});
test("ignores workspace.toml without gateway surfaces", () => {
test("ignores workspace.toml without workspace surfaces", () => {
const root = workspaceRoot();
writeRootWorkspaceToml(root, `
name = "home"
@ -373,14 +373,14 @@ enabled = true
expect(parsed.type).toBe("run");
if (parsed.type === "run") {
expect(parsed.config.gateway).toBeUndefined();
expect(parsed.config.workspace).toBeUndefined();
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("rejects gateway main thread without home channel", () => {
test("rejects workspace main thread without home channel", () => {
expect(() =>
parseConfig(
[
@ -393,10 +393,10 @@ enabled = true
],
{},
)
).toThrow("Cannot set a gateway main thread without a gateway home channel.");
).toThrow("Cannot set a workspace main thread without a workspace home channel.");
});
test("rejects partial gateway workbench channel configuration", () => {
test("rejects partial workspace workbench channel configuration", () => {
expect(() =>
parseConfig(
[
@ -416,7 +416,7 @@ enabled = true
);
});
test("rejects gateway workbench channels that are not separate", () => {
test("rejects workspace workbench channels that are not separate", () => {
expect(() =>
parseConfig(
[
@ -434,7 +434,7 @@ enabled = true
{},
)
).toThrow(
"Discord workbench channels must be separate from the gateway home channel and each other.",
"Discord workbench channels must be separate from the workspace home channel and each other.",
);
});

View file

@ -9,7 +9,7 @@ import {
upsertStopHookConfig,
} from "../src/hook-cli.ts";
describe("discord gateway hook CLI", () => {
describe("discord workspace hook CLI", () => {
test("enables the current hooks feature in config.toml", () => {
expect(enableHooksFeature("model = \"gpt-5\"\n")).toBe(
"model = \"gpt-5\"\n\n[features]\nhooks = true\n",

View file

@ -14,10 +14,10 @@ describe("JsonFileStateStore", () => {
statePath,
`${JSON.stringify({
version: 1,
gateway: {
workspace: {
homeChannelId: "home-channel",
mainThreadId: "codex-gateway-thread",
statusMessageId: "message-gateway-status",
mainThreadId: "codex-workspace-thread",
statusMessageId: "message-workspace-status",
createdAt: "2026-05-11T00:00:00.000Z",
toolsVersion: 1,
delegations: [
@ -100,7 +100,7 @@ describe("JsonFileStateStore", () => {
ownerUserId: "user-1",
participantUserIds: ["user-2", "", "user-2", "user-3"],
cwd: "/workspace/project",
mode: "gateway",
mode: "workspace",
statusMessageId: "message-status-1",
},
{
@ -137,10 +137,10 @@ describe("JsonFileStateStore", () => {
const state = await new JsonFileStateStore(statePath).load();
expect(state.gateway).toEqual({
expect(state.workspace).toEqual({
homeChannelId: "home-channel",
mainThreadId: "codex-gateway-thread",
statusMessageId: "message-gateway-status",
mainThreadId: "codex-workspace-thread",
statusMessageId: "message-workspace-status",
createdAt: "2026-05-11T00:00:00.000Z",
toolsVersion: 1,
delegations: [
@ -221,7 +221,7 @@ describe("JsonFileStateStore", () => {
"user-3",
]);
expect(state.sessions[0]?.cwd).toBe("/workspace/project");
expect(state.sessions[0]?.mode).toBe("gateway");
expect(state.sessions[0]?.mode).toBe("workspace");
expect(state.sessions[0]?.statusMessageId).toBe("message-status-1");
expect(state.sessions[1]?.ownerUserId).toBeUndefined();
expect(state.sessions[1]?.sourceMessageId).toBeUndefined();

View file

@ -1,92 +0,0 @@
#!/usr/bin/env bun
import path from "node:path";
import { dispatchFlowEvent, readFlowEvent, replayFlowEvent } from "./backend.ts";
import { helpText, parseCli } from "./config.ts";
import { serveFlowBackend } from "./server.ts";
import { FlowBackendStore, type FlowRunStatus } from "./store.ts";
await main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
async function main(): Promise<void> {
const cli = parseCli(Bun.argv.slice(2));
if (cli.kind === "help") {
process.stdout.write(helpText());
return;
}
if (cli.kind === "serve") {
const server = serveFlowBackend(cli.config);
process.stdout.write(`codex-flow-systemd-local listening on http://${server.hostname}:${server.port}\n`);
return new Promise(() => undefined);
}
const store = new FlowBackendStore(path.join(cli.config.dataDir, "flow-backend.sqlite"));
try {
if (cli.kind === "dispatch") {
const event = await readFlowEvent(cli.eventPath);
const result = await dispatchFlowEvent({
config: cli.config,
store,
event,
wait: cli.wait,
env: process.env,
});
writeJson(result);
return;
}
if (cli.kind === "list-events") {
writeJson({ events: store.listEvents({ type: cli.type, limit: cli.limit }) });
return;
}
if (cli.kind === "show-event") {
const event = store.getEvent(cli.eventId);
if (!event) {
throw new Error(`Unknown event: ${cli.eventId}`);
}
writeJson({ event, runs: store.listRunsByEvent(cli.eventId) });
return;
}
if (cli.kind === "replay-event") {
writeJson(await replayFlowEvent({
config: cli.config,
store,
eventId: cli.eventId,
wait: cli.wait,
env: process.env,
}));
return;
}
if (cli.kind === "list-runs") {
writeJson({
runs: store.listRuns({
eventId: cli.eventId,
status: cli.status ? requireRunStatus(cli.status) : undefined,
limit: cli.limit,
}),
});
return;
}
if (cli.kind === "show-run") {
const run = store.getRun(cli.runId);
if (!run) {
throw new Error(`Unknown run: ${cli.runId}`);
}
writeJson({ run });
return;
}
} finally {
store.close();
}
}
function writeJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function requireRunStatus(value: string): FlowRunStatus {
if (value === "queued" || value === "running" || value === "completed" || value === "failed") {
return value;
}
throw new Error("run status must be queued, running, completed, or failed");
}

View file

@ -1,119 +0,0 @@
import path from "node:path";
import type { FlowBackendConfig } from "./config.ts";
import { dispatchFlowEvent, normalizeFlowEvent, replayFlowEvent } from "./backend.ts";
import { requestSignature, verifyBodySignature } from "./signature.ts";
import { FlowBackendStore, type FlowRunStatus } from "./store.ts";
export function serveFlowBackend(config: FlowBackendConfig): ReturnType<typeof Bun.serve> {
const store = new FlowBackendStore(path.join(config.dataDir, "flow-backend.sqlite"));
return Bun.serve({
hostname: config.host,
port: config.port,
async fetch(request) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/healthz") {
return json({ ok: true });
}
if (request.method === "POST" && (url.pathname === "/events" || url.pathname === "/flow-events")) {
const body = await request.text();
if (!validSignature(config, body, request.headers)) {
return json({ error: "invalid signature" }, 401);
}
const event = normalizeFlowEvent(JSON.parse(body) as unknown);
const result = await dispatchFlowEvent({ config, store, event });
return json(result, 202);
}
if (request.method === "GET" && url.pathname === "/events") {
return json({
events: store.listEvents({
type: url.searchParams.get("type") ?? undefined,
limit: numberParam(url.searchParams.get("limit")),
}),
});
}
const eventMatch = url.pathname.match(/^\/events\/([^/]+)(?:\/(replay))?$/);
if (eventMatch?.[1] && request.method === "GET" && !eventMatch[2]) {
const eventId = decodeURIComponent(eventMatch[1]);
const event = store.getEvent(eventId);
if (!event) {
return json({ error: "event not found" }, 404);
}
return json({ event, runs: store.listRunsByEvent(eventId) });
}
if (eventMatch?.[1] && eventMatch[2] === "replay" && request.method === "POST") {
const body = await request.text();
if (!validSignature(config, body, request.headers)) {
return json({ error: "invalid signature" }, 401);
}
const params = parseBody(body);
const result = await replayFlowEvent({
config,
store,
eventId: decodeURIComponent(eventMatch[1]),
wait: Boolean(params.wait),
env: process.env,
});
return json(result, 202);
}
if (request.method === "GET" && url.pathname === "/runs") {
const eventId = url.searchParams.get("eventId");
const status = url.searchParams.get("status");
return json({
...(eventId ? { eventId } : {}),
runs: store.listRuns({
eventId: eventId ?? undefined,
status: status ? requireRunStatus(status) : undefined,
limit: numberParam(url.searchParams.get("limit")),
}),
});
}
const runMatch = url.pathname.match(/^\/runs\/([^/]+)$/);
if (runMatch?.[1] && request.method === "GET") {
const run = store.getRun(decodeURIComponent(runMatch[1]));
if (!run) {
return json({ error: "run not found" }, 404);
}
return json({ run });
}
return json({ error: "not found" }, 404);
},
});
}
function validSignature(config: FlowBackendConfig, body: string, headers: Headers): boolean {
return !config.secret || verifyBodySignature(config.secret, body, requestSignature(headers));
}
function numberParam(value: string | null): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseBody(body: string): Record<string, unknown> {
if (!body.trim()) {
return {};
}
const parsed = JSON.parse(body) as unknown;
return isRecord(parsed) ? parsed : {};
}
function requireRunStatus(value: string): FlowRunStatus {
if (value === "queued" || value === "running" || value === "completed" || value === "failed") {
return value;
}
throw new Error("run status must be queued, running, completed, or failed");
}
function json(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value, null, 2), {
status,
headers: { "content-type": "application/json" },
});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -1,27 +0,0 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"target": "ES2022",
"lib": ["ESNext"],
"strict": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"types": ["node", "bun"],
"baseUrl": ".",
"paths": {
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/*": ["../../packages/codex-client/src/*"],
"@peezy.tech/flow-runtime": ["../../packages/flow-runtime/src/index.ts"],
"@peezy.tech/flow-runtime/*": ["../../packages/flow-runtime/src/*"]
}
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View file

@ -1,25 +0,0 @@
{
"name": "codex-gateway-local",
"version": "0.1.0",
"description": "Local Codex gateway server for browser and transport clients.",
"type": "module",
"private": true,
"license": "Apache-2.0",
"bin": {
"codex-gateway-local": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"check:types": "tsc --noEmit",
"start": "bun ./src/index.ts serve",
"test": "bun test test/*.test.ts"
},
"dependencies": {
"@peezy.tech/codex-flows": "workspace:*"
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

View file

@ -1,152 +0,0 @@
#!/usr/bin/env bun
import {
CodexAppServerClient,
CodexStdioTransport,
} from "@peezy.tech/codex-flows";
import {
CodexGatewayProtocolServer,
type CodexGatewayPeer,
} from "@peezy.tech/codex-flows/gateway";
import { parseArgs, type GatewayCliArgs } from "./args.ts";
const defaultAppServerUrl = "ws://127.0.0.1:3585";
async function main(): Promise<void> {
const parsed = parseArgs(Bun.argv.slice(2), process.env);
if (parsed.type === "help") {
process.stdout.write(parsed.text);
return;
}
const client = createAppServerClient(parsed);
client.on("stderr", (line) => process.stderr.write(`${line}\n`));
await client.connect();
const gateway = new CodexGatewayProtocolServer({
appServer: client,
serverName: "codex-gateway-local",
serverVersion: "0.1.0",
});
const peers = new WeakMap<Bun.ServerWebSocket<unknown>, CodexGatewayPeer>();
const server = Bun.serve({
hostname: parsed.hostname,
port: parsed.port,
fetch(request, bunServer) {
if (bunServer.upgrade(request)) {
return undefined;
}
return new Response("Codex gateway WebSocket server\n", {
status: 426,
headers: { "content-type": "text/plain; charset=utf-8" },
});
},
websocket: {
open(socket) {
const peer: CodexGatewayPeer = {
send: (message) => socket.send(message),
};
peers.set(socket, peer);
gateway.addPeer(peer);
},
message(socket, message) {
const peer = peers.get(socket);
if (!peer) {
return;
}
void gateway.handleMessage(peer, websocketMessageToString(message))
.catch((error: unknown) => {
gateway.sendGatewayEvent(peer, {
type: "appServer.error",
at: new Date().toISOString(),
message: errorMessage(error),
});
});
},
close(socket) {
const peer = peers.get(socket);
if (peer) {
gateway.removePeer(peer);
peers.delete(socket);
}
},
},
});
process.stdout.write(
`codex-gateway-local listening on ws://${server.hostname}:${server.port}\n`,
);
process.stdout.write(
`codex-gateway-local app-server ${
parsed.localAppServer
? "local stdio"
: parsed.appServerUrl ??
process.env.CODEX_WORKSPACE_APP_SERVER_WS_URL ??
defaultAppServerUrl
}\n`,
);
await waitForShutdown(server, client);
}
function createAppServerClient(
args: Extract<GatewayCliArgs, { type: "serve" }>,
): CodexAppServerClient {
const appServerUrl =
args.appServerUrl ??
process.env.CODEX_WORKSPACE_APP_SERVER_WS_URL ??
defaultAppServerUrl;
return new CodexAppServerClient({
transport: args.localAppServer
? new CodexStdioTransport({
args: localAppServerArgs(),
requestTimeoutMs: 90_000,
})
: undefined,
webSocketTransportOptions: args.localAppServer
? undefined
: { url: appServerUrl, requestTimeoutMs: 90_000 },
clientName: "codex-gateway-local",
clientTitle: "Codex Gateway Local",
clientVersion: "0.1.0",
});
}
function localAppServerArgs(): string[] {
return [
"app-server",
"--listen",
"stdio://",
"--enable",
"apps",
"--enable",
"hooks",
];
}
function websocketMessageToString(message: string | Buffer): string {
return typeof message === "string" ? message : message.toString("utf8");
}
function waitForShutdown(
server: Bun.Server<unknown>,
client: CodexAppServerClient,
): Promise<void> {
return new Promise((resolve) => {
const shutdown = () => {
process.off("SIGINT", shutdown);
process.off("SIGTERM", shutdown);
server.stop(true);
client.close();
resolve();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
});
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
await main();

View file

@ -33,12 +33,12 @@ import {
type v2,
} from "@peezy.tech/codex-flows/browser";
import {
CodexGatewayClient,
type GatewayEvent,
} from "@peezy.tech/codex-flows/gateway";
CodexWorkspaceBackendClient,
type WorkspaceBackendEvent,
} from "@peezy.tech/codex-flows/workspace-backend";
import { ThemeProvider } from "./components/theme-provider.tsx";
import { gatewayStorageKey, initialGatewayWsUrl } from "./gateway-url.ts";
import { workspaceBackendStorageKey, initialWorkspaceBackendWsUrl } from "./workspace-backend-url.ts";
type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
@ -59,11 +59,11 @@ export function App() {
}
function BareCodexApp() {
const clientRef = useRef<CodexGatewayClient | null>(null);
const clientRef = useRef<CodexWorkspaceBackendClient | null>(null);
const authRef = useRef<CodexAuthClient | null>(null);
const [wsUrl, setWsUrl] = useState(() =>
initialGatewayWsUrl({
envUrl: import.meta.env.VITE_CODEX_GATEWAY_WS_URL,
initialWorkspaceBackendWsUrl({
envUrl: import.meta.env.VITE_CODEX_WORKSPACE_BACKEND_WS_URL,
location: window.location,
storage: window.localStorage,
})
@ -206,7 +206,7 @@ function BareCodexApp() {
}
clientRef.current?.close();
const client = new CodexGatewayClient({
const client = new CodexWorkspaceBackendClient({
webSocketTransportOptions: { url, requestTimeoutMs: 90_000 },
clientName: "bare-web",
clientTitle: "Codex Bare Web",
@ -223,17 +223,17 @@ function BareCodexApp() {
body: previewJson(message.params, 900),
});
});
client.on("gatewayEvent", (event: GatewayEvent) => {
client.on("workspaceBackendEvent", (event: WorkspaceBackendEvent) => {
appendEvent({
kind: "control",
title: `gateway ${event.type}`,
title: `workspace backend ${event.type}`,
body: previewJson(event, 900),
});
});
client.on("error", (eventError: unknown) => {
appendEvent({
kind: "error",
title: "gateway transport error",
title: "workspace backend transport error",
body: errorMessage(eventError),
});
setError(errorMessage(eventError));
@ -255,7 +255,7 @@ function BareCodexApp() {
setError(undefined);
try {
await client.connect();
window.localStorage.setItem(gatewayStorageKey, url);
window.localStorage.setItem(workspaceBackendStorageKey, url);
setConnectedUrl(url);
setStatus("connected");
appendEvent({ kind: "control", title: "connected", body: url });
@ -506,7 +506,7 @@ function BareCodexApp() {
<h1 className="truncate text-base font-semibold">Codex Bare</h1>
</div>
<p className="truncate text-xs text-muted-foreground">
{connectedUrl ?? "No gateway connection"}
{connectedUrl ?? "No workspace backend connection"}
</p>
</div>
<form

View file

@ -1,20 +0,0 @@
export const gatewayStorageKey = "codex-bare.gateway-url";
export type GatewayUrlOptions = {
envUrl?: string;
location: Pick<Location, "host" | "protocol">;
storage?: Pick<Storage, "getItem">;
};
export function initialGatewayWsUrl(options: GatewayUrlOptions): string {
return options.storage?.getItem(gatewayStorageKey) ??
options.envUrl ??
proxiedGatewayWsUrl(options.location);
}
export function proxiedGatewayWsUrl(
location: Pick<Location, "host" | "protocol">,
): string {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${location.host}/__codex-gateway`;
}

View file

@ -0,0 +1,20 @@
export const workspaceBackendStorageKey = "codex-bare.workspace-backend-url";
export type WorkspaceBackendUrlOptions = {
envUrl?: string;
location: Pick<Location, "host" | "protocol">;
storage?: Pick<Storage, "getItem">;
};
export function initialWorkspaceBackendWsUrl(options: WorkspaceBackendUrlOptions): string {
return options.storage?.getItem(workspaceBackendStorageKey) ??
options.envUrl ??
proxiedWorkspaceBackendWsUrl(options.location);
}
export function proxiedWorkspaceBackendWsUrl(
location: Pick<Location, "host" | "protocol">,
): string {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${location.host}/__codex-workspace-backend`;
}

View file

@ -1,41 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
gatewayStorageKey,
initialGatewayWsUrl,
proxiedGatewayWsUrl,
} from "../src/gateway-url.ts";
describe("gateway URLs", () => {
test("uses the proxied gateway path on http origins", () => {
expect(proxiedGatewayWsUrl({ protocol: "http:", host: "localhost:5173" }))
.toBe("ws://localhost:5173/__codex-gateway");
});
test("uses wss for https origins", () => {
expect(proxiedGatewayWsUrl({ protocol: "https:", host: "flows.peezy.tech" }))
.toBe("wss://flows.peezy.tech/__codex-gateway");
});
test("prefers stored gateway URLs over env defaults", () => {
const values = new Map<string, string>([
[gatewayStorageKey, "ws://127.0.0.1:4599"],
]);
expect(
initialGatewayWsUrl({
envUrl: "ws://127.0.0.1:3586",
location: { protocol: "http:", host: "localhost:5173" },
storage: { getItem: (key) => values.get(key) ?? null },
}),
).toBe("ws://127.0.0.1:4599");
});
test("uses env defaults before deriving the proxied URL", () => {
expect(
initialGatewayWsUrl({
envUrl: "ws://127.0.0.1:3586",
location: { protocol: "http:", host: "localhost:5173" },
}),
).toBe("ws://127.0.0.1:3586");
});
});

View file

@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import {
workspaceBackendStorageKey,
initialWorkspaceBackendWsUrl,
proxiedWorkspaceBackendWsUrl,
} from "../src/workspace-backend-url.ts";
describe("workspace backend URLs", () => {
test("uses the proxied workspace backend path on http origins", () => {
expect(proxiedWorkspaceBackendWsUrl({ protocol: "http:", host: "localhost:5173" }))
.toBe("ws://localhost:5173/__codex-workspace-backend");
});
test("uses wss for https origins", () => {
expect(proxiedWorkspaceBackendWsUrl({ protocol: "https:", host: "flows.peezy.tech" }))
.toBe("wss://flows.peezy.tech/__codex-workspace-backend");
});
test("prefers stored workspace backend URLs over env defaults", () => {
const values = new Map<string, string>([
[workspaceBackendStorageKey, "ws://127.0.0.1:4599"],
]);
expect(
initialWorkspaceBackendWsUrl({
envUrl: "ws://127.0.0.1:3586",
location: { protocol: "http:", host: "localhost:5173" },
storage: { getItem: (key) => values.get(key) ?? null },
}),
).toBe("ws://127.0.0.1:4599");
});
test("uses env defaults before deriving the proxied URL", () => {
expect(
initialWorkspaceBackendWsUrl({
envUrl: "ws://127.0.0.1:3586",
location: { protocol: "http:", host: "localhost:5173" },
}),
).toBe("ws://127.0.0.1:3586");
});
});

View file

@ -25,7 +25,7 @@
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/browser": ["../../packages/codex-client/src/browser.ts"],
"@peezy.tech/codex-flows/flows": ["../../packages/codex-client/src/app-server/flows.ts"],
"@peezy.tech/codex-flows/gateway": ["../../packages/codex-client/src/gateway/index.ts"],
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"],
"@peezy.tech/codex-flows/generated": ["../../packages/codex-client/src/app-server/generated/index.ts"],
"@peezy.tech/codex-flows/generated/*": ["../../packages/codex-client/src/app-server/generated/*"],
"@peezy.tech/codex-flows/rpc": ["../../packages/codex-client/src/app-server/rpc.ts"],

View file

@ -11,7 +11,7 @@
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/browser": ["../../packages/codex-client/src/browser.ts"],
"@peezy.tech/codex-flows/flows": ["../../packages/codex-client/src/app-server/flows.ts"],
"@peezy.tech/codex-flows/gateway": ["../../packages/codex-client/src/gateway/index.ts"],
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"],
"@peezy.tech/codex-flows/generated": ["../../packages/codex-client/src/app-server/generated/index.ts"],
"@peezy.tech/codex-flows/generated/*": ["../../packages/codex-client/src/app-server/generated/*"],
"@peezy.tech/codex-flows/rpc": ["../../packages/codex-client/src/app-server/rpc.ts"],

View file

@ -7,8 +7,8 @@ const allowedHosts = (process.env.VITE_ALLOWED_HOSTS ?? "")
.split(",")
.map((host) => host.trim())
.filter(Boolean);
const codexGatewayTarget =
process.env.VITE_CODEX_GATEWAY_PROXY_TARGET ?? "ws://127.0.0.1:3586";
const codexWorkspaceBackendTarget =
process.env.VITE_CODEX_WORKSPACE_BACKEND_PROXY_TARGET ?? "ws://127.0.0.1:3586";
export default defineConfig({
base: process.env.VITE_BASE_PATH ?? "/",
@ -20,9 +20,9 @@ export default defineConfig({
__dirname,
"../../packages/codex-client/src/browser.ts",
),
"@peezy.tech/codex-flows/gateway": path.resolve(
"@peezy.tech/codex-flows/workspace-backend": path.resolve(
__dirname,
"../../packages/codex-client/src/gateway/index.ts",
"../../packages/codex-client/src/workspace-backend/index.ts",
),
"@peezy.tech/codex-flows": path.resolve(
__dirname,
@ -46,8 +46,8 @@ export default defineConfig({
server: {
allowedHosts: allowedHosts.length > 0 ? allowedHosts : undefined,
proxy: {
"/__codex-gateway": {
target: codexGatewayTarget,
"/__codex-workspace-backend": {
target: codexWorkspaceBackendTarget,
ws: true,
rewrite: () => "/",
configure: (proxy) => {

View file

@ -1,12 +1,12 @@
{
"name": "codex-flow-systemd-local",
"name": "codex-workspace-backend-local",
"version": "0.1.0",
"description": "Local flow execution backend suitable for a systemd-managed service.",
"description": "Local Codex workspace backend with app-server control and flow capabilities.",
"type": "module",
"private": true,
"license": "Apache-2.0",
"bin": {
"codex-flow-systemd-local": "./src/index.ts"
"codex-workspace-backend-local": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
@ -15,6 +15,7 @@
"test": "bun test test/*.test.ts"
},
"dependencies": {
"@peezy.tech/codex-flows": "workspace:*",
"@peezy.tech/flow-runtime": "workspace:*"
},
"devDependencies": {

View file

@ -1,10 +1,16 @@
export type GatewayCliArgs =
export type WorkspaceBackendCliArgs =
| {
type: "serve";
port: number;
hostname: string;
appServerUrl?: string;
localAppServer: boolean;
cwd?: string;
dataDir?: string;
secret?: string;
executor?: string;
bunCommand?: string;
flowRunnerPath?: string;
}
| {
type: "help";
@ -14,7 +20,7 @@ export type GatewayCliArgs =
export function parseArgs(
argv: string[],
env: Record<string, string | undefined> = process.env,
): GatewayCliArgs {
): WorkspaceBackendCliArgs {
if (argv.includes("--help") || argv.includes("-h")) {
return { type: "help", text: helpText() };
}
@ -23,20 +29,26 @@ export function parseArgs(
throw new Error(`Unknown command: ${command}`);
}
const appServerUrl =
stringFlag(argv, "app-server-url") ?? env.CODEX_GATEWAY_APP_SERVER_URL;
stringFlag(argv, "app-server-url") ?? env.CODEX_WORKSPACE_BACKEND_APP_SERVER_URL;
const localAppServer = booleanFlag(argv, "local-app-server") ||
booleanEnv(env.CODEX_GATEWAY_LOCAL_APP_SERVER);
booleanEnv(env.CODEX_WORKSPACE_BACKEND_LOCAL_APP_SERVER);
if (appServerUrl && localAppServer) {
throw new Error("Cannot set both --local-app-server and --app-server-url.");
}
return {
type: "serve",
port: integerFlag(argv, "port") ??
integerEnv(env.CODEX_GATEWAY_PORT) ??
integerEnv(env.CODEX_WORKSPACE_BACKEND_PORT) ??
3586,
hostname: stringFlag(argv, "host") ?? env.CODEX_GATEWAY_HOST ?? "127.0.0.1",
hostname: stringFlag(argv, "host") ?? env.CODEX_WORKSPACE_BACKEND_HOST ?? "127.0.0.1",
appServerUrl,
localAppServer,
cwd: stringFlag(argv, "cwd"),
dataDir: stringFlag(argv, "data-dir"),
secret: stringFlag(argv, "secret"),
executor: stringFlag(argv, "executor"),
bunCommand: stringFlag(argv, "bun"),
flowRunnerPath: stringFlag(argv, "flow-runner"),
};
}
@ -78,22 +90,28 @@ function booleanEnv(value: string | undefined): boolean {
}
function helpText(): string {
return `codex-gateway-local serves the local Codex gateway protocol.
return `codex-workspace-backend-local serves the local Codex workspace backend protocol.
Usage:
codex-gateway-local serve [options]
codex-workspace-backend-local serve [options]
Options:
--host <host> Host to bind. Defaults to 127.0.0.1.
--port <port> Port to bind. Defaults to 3586.
--app-server-url <url> Existing app-server WebSocket URL.
--local-app-server Start a local app-server over stdio.
--cwd <dir> Workspace root for flow discovery.
--data-dir <dir> Durable flow backend state directory.
--secret <secret> Optional HMAC secret for HTTP flow dispatch.
--executor <executor> Flow executor: direct or systemd-run.
--bun <path> Bun command for flow execution.
--flow-runner <path> Flow runner script path.
--help, -h Show this help.
Environment:
CODEX_GATEWAY_HOST
CODEX_GATEWAY_PORT
CODEX_GATEWAY_APP_SERVER_URL
CODEX_GATEWAY_LOCAL_APP_SERVER
CODEX_WORKSPACE_BACKEND_HOST
CODEX_WORKSPACE_BACKEND_PORT
CODEX_WORKSPACE_BACKEND_APP_SERVER_URL
CODEX_WORKSPACE_BACKEND_LOCAL_APP_SERVER
`;
}

View file

@ -157,7 +157,7 @@ function createRunRecord(
flowName: flow.manifest.name,
stepName: step.name,
status: "queued",
backend: "systemd-local",
backend: "workspace-local",
executor: config.executor,
eventPath,
createdAt: new Date().toISOString(),

View file

@ -203,19 +203,19 @@ export function parseCli(argv: string[], env: Record<string, string | undefined>
}
export function defaultFlowRunnerPath(): string {
return path.resolve(import.meta.dir, "..", "..", "flow-runner", "src", "index.ts");
return path.resolve(import.meta.dir, "..", "..", "..", "flow-runner", "src", "index.ts");
}
export function helpText(): string {
return [
"Usage:",
" codex-flow-systemd-local serve [--cwd <dir>] [--data-dir <dir>] [--host <host>] [--port <port>]",
" codex-flow-systemd-local dispatch --event <event.json> [--cwd <dir>] [--data-dir <dir>] [--wait]",
" codex-flow-systemd-local list-events [--type <type>] [--limit <n>]",
" codex-flow-systemd-local show-event <event-id>",
" codex-flow-systemd-local replay-event <event-id> [--wait]",
" codex-flow-systemd-local list-runs [--event-id <event-id>] [--status <status>] [--limit <n>]",
" codex-flow-systemd-local show-run <run-id>",
" codex-workspace-backend-local serve [--cwd <dir>] [--data-dir <dir>] [--host <host>] [--port <port>]",
" codex-workspace-backend-local dispatch --event <event.json> [--cwd <dir>] [--data-dir <dir>] [--wait]",
" codex-workspace-backend-local list-events [--type <type>] [--limit <n>]",
" codex-workspace-backend-local show-event <event-id>",
" codex-workspace-backend-local replay-event <event-id> [--wait]",
" codex-workspace-backend-local list-runs [--event-id <event-id>] [--status <status>] [--limit <n>]",
" codex-workspace-backend-local show-run <run-id>",
"",
"Environment:",
" CODEX_FLOW_BACKEND_SECRET Optional HMAC secret for HTTP dispatches",

View file

@ -0,0 +1,195 @@
import path from "node:path";
import type { FlowBackendConfig } from "./config.ts";
import { dispatchFlowEvent, normalizeFlowEvent, replayFlowEvent } from "./backend.ts";
import { requestSignature, verifyBodySignature } from "./signature.ts";
import { FlowBackendStore, type FlowRunStatus } from "./store.ts";
export type WorkspaceFlowCapabilityOptions = {
config: FlowBackendConfig;
store?: FlowBackendStore;
env?: Record<string, string | undefined>;
};
export class WorkspaceFlowCapability {
readonly config: FlowBackendConfig;
readonly store: FlowBackendStore;
#env: Record<string, string | undefined>;
#ownsStore: boolean;
constructor(options: WorkspaceFlowCapabilityOptions) {
this.config = options.config;
this.store = options.store ??
new FlowBackendStore(path.join(options.config.dataDir, "flow-backend.sqlite"));
this.#env = options.env ?? process.env;
this.#ownsStore = !options.store;
}
close(): void {
if (this.#ownsStore) {
this.store.close();
}
}
async dispatch(event: unknown): Promise<unknown> {
return await dispatchFlowEvent({
config: this.config,
store: this.store,
event: normalizeFlowEvent(event),
env: this.#env,
});
}
async replay(eventId: string, options: { wait?: boolean } = {}): Promise<unknown> {
return await replayFlowEvent({
config: this.config,
store: this.store,
eventId,
wait: Boolean(options.wait),
env: this.#env,
});
}
listEvents(options: { type?: string; limit?: number } = {}): unknown {
return {
events: this.store.listEvents({
type: options.type,
limit: options.limit,
}),
};
}
getEvent(eventId: string): unknown {
const event = this.store.getEvent(eventId);
if (!event) {
throw new Error(`Unknown event: ${eventId}`);
}
return { event, runs: this.store.listRunsByEvent(eventId) };
}
listRuns(options: {
eventId?: string;
status?: string;
limit?: number;
} = {}): unknown {
return {
...(options.eventId ? { eventId: options.eventId } : {}),
runs: this.store.listRuns({
eventId: options.eventId,
status: options.status ? requireRunStatus(options.status) : undefined,
limit: options.limit,
}),
};
}
getRun(runId: string): unknown {
const run = this.store.getRun(runId);
if (!run) {
throw new Error(`Unknown run: ${runId}`);
}
return { run };
}
async handleHttp(request: Request): Promise<Response | undefined> {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/healthz") {
return json({ ok: true });
}
if (request.method === "POST" && (url.pathname === "/events" || url.pathname === "/flow-events")) {
const body = await request.text();
if (!validSignature(this.config, body, request.headers)) {
return json({ error: "invalid signature" }, 401);
}
return json(await this.dispatch(JSON.parse(body) as unknown), 202);
}
if (request.method === "GET" && url.pathname === "/events") {
return json(this.listEvents({
type: url.searchParams.get("type") ?? undefined,
limit: numberParam(url.searchParams.get("limit")),
}));
}
const eventMatch = url.pathname.match(/^\/events\/([^/]+)(?:\/(replay))?$/);
if (eventMatch?.[1] && request.method === "GET" && !eventMatch[2]) {
try {
return json(this.getEvent(decodeURIComponent(eventMatch[1])));
} catch {
return json({ error: "event not found" }, 404);
}
}
if (eventMatch?.[1] && eventMatch[2] === "replay" && request.method === "POST") {
const body = await request.text();
if (!validSignature(this.config, body, request.headers)) {
return json({ error: "invalid signature" }, 401);
}
const params = parseBody(body);
const result = await this.replay(decodeURIComponent(eventMatch[1]), {
wait: Boolean(params.wait),
});
return json(result, 202);
}
if (request.method === "GET" && url.pathname === "/runs") {
return json(this.listRuns({
eventId: url.searchParams.get("eventId") ?? undefined,
status: url.searchParams.get("status") ?? undefined,
limit: numberParam(url.searchParams.get("limit")),
}));
}
const runMatch = url.pathname.match(/^\/runs\/([^/]+)$/);
if (runMatch?.[1] && request.method === "GET") {
try {
return json(this.getRun(decodeURIComponent(runMatch[1])));
} catch {
return json({ error: "run not found" }, 404);
}
}
return undefined;
}
}
export function serveFlowBackend(config: FlowBackendConfig): ReturnType<typeof Bun.serve> {
const flow = new WorkspaceFlowCapability({ config });
return Bun.serve({
hostname: config.host,
port: config.port,
async fetch(request) {
return await flow.handleHttp(request) ?? json({ error: "not found" }, 404);
},
});
}
function validSignature(config: FlowBackendConfig, body: string, headers: Headers): boolean {
return !config.secret || verifyBodySignature(config.secret, body, requestSignature(headers));
}
function numberParam(value: string | null): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseBody(body: string): Record<string, unknown> {
if (!body.trim()) {
return {};
}
const parsed = JSON.parse(body) as unknown;
return isRecord(parsed) ? parsed : {};
}
function requireRunStatus(value: string): FlowRunStatus {
if (value === "queued" || value === "running" || value === "completed" || value === "failed") {
return value;
}
throw new Error("run status must be queued, running, completed, or failed");
}
function json(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value, null, 2), {
status,
headers: { "content-type": "application/json" },
});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -11,7 +11,7 @@ export type FlowRunRecord = {
flowName: string;
stepName: string;
status: FlowRunStatus;
backend: "systemd-local";
backend: "workspace-local";
executor: string;
unit?: string;
eventPath: string;
@ -252,7 +252,7 @@ function rowToRunRecord(row: unknown): FlowRunRecord {
flowName: String(row.flow_name),
stepName: String(row.step_name),
status: String(row.status) as FlowRunStatus,
backend: "systemd-local",
backend: "workspace-local",
executor: String(row.executor),
...(typeof row.unit === "string" ? { unit: row.unit } : {}),
eventPath: String(row.event_path),

View file

@ -0,0 +1,355 @@
#!/usr/bin/env bun
import {
CodexAppServerClient,
CodexStdioTransport,
} from "@peezy.tech/codex-flows";
import {
CodexWorkspaceBackendProtocolServer,
type WorkspaceBackendMethodHandler,
type CodexWorkspaceBackendPeer,
} from "@peezy.tech/codex-flows/workspace-backend";
import path from "node:path";
import { parseArgs, type WorkspaceBackendCliArgs } from "./args.ts";
import { dispatchFlowEvent, readFlowEvent, replayFlowEvent } from "./flow/backend.ts";
import {
helpText as flowHelpText,
parseCli as parseFlowCli,
readConfig as readFlowConfig,
type FlowBackendCli,
type FlowBackendConfig,
type FlowBackendExecutor,
} from "./flow/config.ts";
import { WorkspaceFlowCapability } from "./flow/server.ts";
import { FlowBackendStore, type FlowRunStatus } from "./flow/store.ts";
const defaultAppServerUrl = "ws://127.0.0.1:3585";
async function main(): Promise<void> {
const argv = Bun.argv.slice(2);
if (isFlowCommand(argv[0])) {
await runFlowCli(parseFlowCli(argv, process.env));
return;
}
if (argv[0] === "flow") {
await runFlowCli(parseFlowCli(argv.slice(1), process.env));
return;
}
const parsed = parseArgs(argv, process.env);
if (parsed.type === "help") {
process.stdout.write(parsed.text);
return;
}
const client = createAppServerClient(parsed);
client.on("stderr", (line) => process.stderr.write(`${line}\n`));
await client.connect();
const flow = new WorkspaceFlowCapability({
config: workspaceFlowConfig(parsed),
env: process.env,
});
const workspaceBackend = new CodexWorkspaceBackendProtocolServer({
appServer: client,
serverName: "codex-workspace-backend-local",
serverVersion: "0.1.0",
flowInspection: true,
methods: flowMethodHandlers(flow),
});
const peers = new WeakMap<Bun.ServerWebSocket<unknown>, CodexWorkspaceBackendPeer>();
const server = Bun.serve({
hostname: parsed.hostname,
port: parsed.port,
async fetch(request, bunServer) {
if (bunServer.upgrade(request)) {
return undefined;
}
const flowResponse = await flow.handleHttp(request);
if (flowResponse) {
return flowResponse;
}
return new Response("Codex workspace backend WebSocket server\n", {
status: 426,
headers: { "content-type": "text/plain; charset=utf-8" },
});
},
websocket: {
open(socket) {
const peer: CodexWorkspaceBackendPeer = {
send: (message) => socket.send(message),
};
peers.set(socket, peer);
workspaceBackend.addPeer(peer);
},
message(socket, message) {
const peer = peers.get(socket);
if (!peer) {
return;
}
void workspaceBackend.handleMessage(peer, websocketMessageToString(message))
.catch((error: unknown) => {
workspaceBackend.sendWorkspaceBackendEvent(peer, {
type: "appServer.error",
at: new Date().toISOString(),
message: errorMessage(error),
});
});
},
close(socket) {
const peer = peers.get(socket);
if (peer) {
workspaceBackend.removePeer(peer);
peers.delete(socket);
}
},
},
});
process.stdout.write(
`codex-workspace-backend-local listening on ws://${server.hostname}:${server.port}\n`,
);
process.stdout.write(
`codex-workspace-backend-local app-server ${
parsed.localAppServer
? "local stdio"
: parsed.appServerUrl ??
process.env.CODEX_WORKSPACE_APP_SERVER_WS_URL ??
defaultAppServerUrl
}\n`,
);
await waitForShutdown(server, client, flow);
}
function createAppServerClient(
args: Extract<WorkspaceBackendCliArgs, { type: "serve" }>,
): CodexAppServerClient {
const appServerUrl =
args.appServerUrl ??
process.env.CODEX_WORKSPACE_APP_SERVER_WS_URL ??
defaultAppServerUrl;
return new CodexAppServerClient({
transport: args.localAppServer
? new CodexStdioTransport({
args: localAppServerArgs(),
requestTimeoutMs: 90_000,
})
: undefined,
webSocketTransportOptions: args.localAppServer
? undefined
: { url: appServerUrl, requestTimeoutMs: 90_000 },
clientName: "codex-workspace-backend-local",
clientTitle: "Codex Workspace Backend Local",
clientVersion: "0.1.0",
});
}
function workspaceFlowConfig(
args: Extract<WorkspaceBackendCliArgs, { type: "serve" }>,
): FlowBackendConfig {
return readFlowConfig(process.env, {
host: args.hostname,
port: args.port,
...(args.cwd ? { cwd: args.cwd } : {}),
...(args.dataDir ? { dataDir: args.dataDir } : {}),
...(args.secret ? { secret: args.secret } : {}),
...(args.executor ? { executor: requireFlowExecutor(args.executor) } : {}),
...(args.bunCommand ? { bunCommand: args.bunCommand } : {}),
...(args.flowRunnerPath ? { flowRunnerPath: args.flowRunnerPath } : {}),
});
}
function flowMethodHandlers(
flow: WorkspaceFlowCapability,
): Record<string, WorkspaceBackendMethodHandler> {
return {
"flow.dispatch": async (params) => await flow.dispatch(record(params).event ?? params),
"flow.replay": async (params) => {
const input = record(params);
return await flow.replay(requiredString(input.eventId, "eventId"), {
wait: Boolean(input.wait),
});
},
"flow.listEvents": (params) => flow.listEvents({
type: stringValue(record(params).type),
limit: positiveIntegerValue(record(params).limit),
}),
"flow.getEvent": (params) =>
flow.getEvent(requiredString(record(params).eventId, "eventId")),
"flow.listRuns": (params) => flow.listRuns({
eventId: stringValue(record(params).eventId),
status: stringValue(record(params).status),
limit: positiveIntegerValue(record(params).limit),
}),
"flow.getRun": (params) =>
flow.getRun(requiredString(record(params).runId, "runId")),
};
}
function localAppServerArgs(): string[] {
return [
"app-server",
"--listen",
"stdio://",
"--enable",
"apps",
"--enable",
"hooks",
];
}
function websocketMessageToString(message: string | Buffer): string {
return typeof message === "string" ? message : message.toString("utf8");
}
function waitForShutdown(
server: Bun.Server<unknown>,
client: CodexAppServerClient,
flow: WorkspaceFlowCapability,
): Promise<void> {
return new Promise((resolve) => {
const shutdown = () => {
process.off("SIGINT", shutdown);
process.off("SIGTERM", shutdown);
server.stop(true);
client.close();
flow.close();
resolve();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
});
}
async function runFlowCli(cli: FlowBackendCli): Promise<void> {
if (cli.kind === "help") {
process.stdout.write(flowHelpText());
return;
}
const store = new FlowBackendStore(path.join(cli.config.dataDir, "flow-backend.sqlite"));
try {
if (cli.kind === "serve") {
throw new Error("Use `codex-workspace-backend-local serve` for the networked workspace backend.");
}
if (cli.kind === "dispatch") {
const event = await readFlowEvent(cli.eventPath);
writeJson(await dispatchFlowEvent({
config: cli.config,
store,
event,
wait: cli.wait,
env: process.env,
}));
return;
}
if (cli.kind === "list-events") {
writeJson({ events: store.listEvents({ type: cli.type, limit: cli.limit }) });
return;
}
if (cli.kind === "show-event") {
const event = store.getEvent(cli.eventId);
if (!event) {
throw new Error(`Unknown event: ${cli.eventId}`);
}
writeJson({ event, runs: store.listRunsByEvent(cli.eventId) });
return;
}
if (cli.kind === "replay-event") {
writeJson(await replayFlowEvent({
config: cli.config,
store,
eventId: cli.eventId,
wait: cli.wait,
env: process.env,
}));
return;
}
if (cli.kind === "list-runs") {
writeJson({
runs: store.listRuns({
eventId: cli.eventId,
status: cli.status ? requireRunStatus(cli.status) : undefined,
limit: cli.limit,
}),
});
return;
}
if (cli.kind === "show-run") {
const run = store.getRun(cli.runId);
if (!run) {
throw new Error(`Unknown run: ${cli.runId}`);
}
writeJson({ run });
}
} finally {
store.close();
}
}
function isFlowCommand(command: string | undefined): boolean {
return command === "dispatch" ||
command === "list-events" ||
command === "events" ||
command === "show-event" ||
command === "event" ||
command === "replay-event" ||
command === "replay" ||
command === "list-runs" ||
command === "runs" ||
command === "show-run" ||
command === "run";
}
function writeJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function requireRunStatus(value: string): FlowRunStatus {
if (value === "queued" || value === "running" || value === "completed" || value === "failed") {
return value;
}
throw new Error("run status must be queued, running, completed, or failed");
}
function requireFlowExecutor(value: string): FlowBackendExecutor {
if (value === "direct" || value === "systemd-run") {
return value;
}
throw new Error("executor must be direct or systemd-run");
}
function requiredString(value: unknown, name: string): string {
const result = stringValue(value);
if (!result) {
throw new Error(`Missing required argument: ${name}`);
}
return result;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function positiveIntegerValue(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.trunc(value);
}
if (typeof value !== "string" || !value.trim()) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
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);
}
await main();

View file

@ -3,8 +3,8 @@ import { describe, expect, test } from "bun:test";
import { parseArgs } from "../src/args.ts";
describe("parseArgs", () => {
test("defaults to serving the local gateway port", () => {
expect(parseArgs([], {})).toEqual({
test("defaults to serving the local workspace backend port", () => {
expect(parseArgs([], {})).toMatchObject({
type: "serve",
hostname: "127.0.0.1",
port: 3586,
@ -23,7 +23,7 @@ describe("parseArgs", () => {
"--app-server-url",
"ws://127.0.0.1:3585",
], {}),
).toEqual({
).toMatchObject({
type: "serve",
hostname: "0.0.0.0",
port: 4599,
@ -46,11 +46,11 @@ describe("parseArgs", () => {
test("reads environment overrides", () => {
expect(
parseArgs([], {
CODEX_GATEWAY_HOST: "0.0.0.0",
CODEX_GATEWAY_PORT: "4599",
CODEX_GATEWAY_LOCAL_APP_SERVER: "yes",
CODEX_WORKSPACE_BACKEND_HOST: "0.0.0.0",
CODEX_WORKSPACE_BACKEND_PORT: "4599",
CODEX_WORKSPACE_BACKEND_LOCAL_APP_SERVER: "yes",
}),
).toEqual({
).toMatchObject({
type: "serve",
hostname: "0.0.0.0",
port: 4599,

View file

@ -2,11 +2,11 @@ import { expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { dispatchFlowEvent, replayFlowEvent } from "../src/backend.ts";
import { parseCli, readConfig } from "../src/config.ts";
import { flowCommand } from "../src/executor.ts";
import { requestSignature, signBody, verifyBodySignature } from "../src/signature.ts";
import { FlowBackendStore } from "../src/store.ts";
import { dispatchFlowEvent, replayFlowEvent } from "../src/flow/backend.ts";
import { parseCli, readConfig } from "../src/flow/config.ts";
import { flowCommand } from "../src/flow/executor.ts";
import { requestSignature, signBody, verifyBodySignature } from "../src/flow/signature.ts";
import { FlowBackendStore } from "../src/flow/store.ts";
test("signs and verifies dispatch bodies", () => {
const body = JSON.stringify({ id: "event-1" });

View file

@ -0,0 +1,191 @@
import { expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
CodexWorkspaceBackendProtocolServer,
type CodexWorkspaceBackendAppServer,
type CodexWorkspaceBackendPeer,
} from "@peezy.tech/codex-flows/workspace-backend";
import { CodexEventEmitter } from "../../../packages/codex-client/src/app-server/events.ts";
import { readConfig } from "../src/flow/config.ts";
import { WorkspaceFlowCapability } from "../src/flow/server.ts";
test("networked local workspace backend serves control WebSocket and flow HTTP routes", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "workspace-backend-"));
const appServer = new FakeAppServer();
const flow = new WorkspaceFlowCapability({
config: readConfig({}, {
cwd: directory,
dataDir: path.join(directory, ".codex", "flow-backend"),
host: "127.0.0.1",
port: 0,
executor: "direct",
bunCommand: process.execPath,
}),
env: {},
});
const workspaceBackend = new CodexWorkspaceBackendProtocolServer({
appServer,
flowInspection: true,
methods: {
"flow.listEvents": () => flow.listEvents(),
},
});
const peers = new WeakMap<Bun.ServerWebSocket<unknown>, CodexWorkspaceBackendPeer>();
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request, bunServer) {
if (bunServer.upgrade(request)) {
return undefined;
}
return await flow.handleHttp(request) ??
new Response("workspace backend", { status: 426 });
},
websocket: {
open(socket) {
const peer: CodexWorkspaceBackendPeer = {
send: (message) => socket.send(message),
};
peers.set(socket, peer);
workspaceBackend.addPeer(peer);
},
message(socket, message) {
const peer = peers.get(socket);
if (!peer) {
return;
}
void workspaceBackend.handleMessage(peer, websocketMessageToString(message));
},
close(socket) {
const peer = peers.get(socket);
if (peer) {
workspaceBackend.removePeer(peer);
peers.delete(socket);
}
},
},
});
try {
const health = await fetch(`http://127.0.0.1:${server.port}/healthz`);
expect(health.status).toBe(200);
expect(await health.json()).toEqual({ ok: true });
const responses = await websocketRequests(
`ws://127.0.0.1:${server.port}/__codex-workspace-backend`,
[
{
jsonrpc: "2.0",
id: "initialize",
method: "workspace.initialize",
params: {
clientInfo: { name: "test", title: "Test", version: "0.1.0" },
capabilities: { appServerPassThrough: true },
},
},
{
jsonrpc: "2.0",
id: "threads",
method: "appServer.call",
params: { method: "thread/list", params: { limit: 1 } },
},
{
jsonrpc: "2.0",
id: "events",
method: "flow.listEvents",
params: {},
},
],
);
expect(responses.get("initialize")?.result).toMatchObject({
ok: true,
capabilities: {
appServerPassThrough: true,
flowInspection: true,
workspaceMethods: ["flow.listEvents"],
},
});
expect(responses.get("threads")?.result).toEqual({ threads: [] });
expect(appServer.requests).toEqual([
{ method: "thread/list", params: { limit: 1 } },
]);
expect(responses.get("events")?.result).toEqual({ events: [] });
} finally {
server.stop(true);
flow.close();
await rm(directory, { recursive: true, force: true });
}
});
class FakeAppServer extends CodexEventEmitter implements CodexWorkspaceBackendAppServer {
requests: Array<{ method: string; params?: unknown }> = [];
async request<T = unknown>(method: string, params?: unknown): Promise<T> {
this.requests.push({ method, params });
return { threads: [] } as T;
}
notify(): void {}
respond(): void {}
respondError(): void {}
}
type RpcResponse = {
id: string | number;
result?: unknown;
error?: { code: number; message: string };
};
function websocketRequests(
url: string,
requests: Array<Record<string, unknown>>,
): Promise<Map<string | number, RpcResponse>> {
return new Promise((resolve, reject) => {
const responses = new Map<string | number, RpcResponse>();
const socket = new WebSocket(url);
const timeout = setTimeout(() => {
socket.close();
reject(new Error("Timed out waiting for WebSocket responses."));
}, 2000);
socket.addEventListener("open", () => {
for (const request of requests) {
socket.send(JSON.stringify(request));
}
});
socket.addEventListener("message", (event) => {
const parsed = JSON.parse(String(event.data)) as unknown;
if (!isRpcResponse(parsed)) {
return;
}
responses.set(parsed.id, parsed);
if (responses.size === requests.length) {
clearTimeout(timeout);
socket.close();
resolve(responses);
}
});
socket.addEventListener("error", () => {
clearTimeout(timeout);
reject(new Error("WebSocket request failed."));
});
});
}
function websocketMessageToString(message: string | Buffer): string {
return typeof message === "string" ? message : message.toString("utf8");
}
function isRpcResponse(value: unknown): value is RpcResponse {
return Boolean(
value &&
typeof value === "object" &&
"id" in value &&
(typeof (value as { id: unknown }).id === "string" ||
typeof (value as { id: unknown }).id === "number"),
);
}

View file

@ -18,7 +18,9 @@
"baseUrl": ".",
"paths": {
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/gateway": ["../../packages/codex-client/src/gateway/index.ts"]
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"],
"@peezy.tech/flow-runtime": ["../../packages/flow-runtime/src/index.ts"],
"@peezy.tech/flow-runtime/*": ["../../packages/flow-runtime/src/*"]
}
},
"include": ["src", "test"]

View file

@ -37,21 +37,6 @@
"typescript": "catalog:",
},
},
"apps/flow-backend-systemd-local": {
"name": "codex-flow-systemd-local",
"version": "0.1.0",
"bin": {
"codex-flow-systemd-local": "./src/index.ts",
},
"dependencies": {
"@peezy.tech/flow-runtime": "workspace:*",
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
},
},
"apps/flow-runner": {
"name": "codex-flow-runner",
"version": "0.1.0",
@ -67,21 +52,6 @@
"typescript": "catalog:",
},
},
"apps/gateway": {
"name": "codex-gateway-local",
"version": "0.1.0",
"bin": {
"codex-gateway-local": "./src/index.ts",
},
"dependencies": {
"@peezy.tech/codex-flows": "workspace:*",
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
},
},
"apps/web": {
"name": "web",
"version": "0.0.1",
@ -102,6 +72,22 @@
"vite": "catalog:",
},
},
"apps/workspace-backend": {
"name": "codex-workspace-backend-local",
"version": "0.1.0",
"bin": {
"codex-workspace-backend-local": "./src/index.ts",
},
"dependencies": {
"@peezy.tech/codex-flows": "workspace:*",
"@peezy.tech/flow-runtime": "workspace:*",
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
},
},
"docs": {
"name": "@peezy.tech/codex-flow-docs",
"version": "0.1.0",
@ -735,9 +721,7 @@
"codex-flow-runner": ["codex-flow-runner@workspace:apps/flow-runner"],
"codex-flow-systemd-local": ["codex-flow-systemd-local@workspace:apps/flow-backend-systemd-local"],
"codex-gateway-local": ["codex-gateway-local@workspace:apps/gateway"],
"codex-workspace-backend-local": ["codex-workspace-backend-local@workspace:apps/workspace-backend"],
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],

View file

@ -27,7 +27,7 @@ The same flow package can run:
- directly through `codex-flow-runner`
- synchronously through `@peezy.tech/flow-runtime/local-client`
- through `codex-flow-systemd-local`
- through the workspace backend's local flow capability
- through a Convex control plane plus an external worker
- through any app-owned backend adapter that preserves the event/result ABI

View file

@ -1,6 +1,6 @@
---
title: Backends
description: Compare local memory, file state, systemd-local, HTTP adapters, and Convex.
description: Compare local memory, file state, workspace flow backends, HTTP adapters, and Convex.
---
# Backends
@ -15,11 +15,12 @@ event ids, list/get, and replay across client instances.
Use it for product CLIs, tests, and local development.
## systemd-local
## Workspace flow capability
The systemd-local backend accepts HTTP dispatches, stores events and runs in
The workspace flow capability accepts dispatches, stores events and runs in
SQLite, writes event JSON files, and starts local steps directly or through
`systemd-run`.
`systemd-run`. Embedded workspace backends can call it directly; the networked
local workspace backend also mounts compatible HTTP routes.
Use it for a small host-level service where local system tools and Codex are
available.

View file

@ -24,7 +24,7 @@ It does not own product-specific completion:
- payment state
- minting
- Discord write tools
- gateway backends and arbitrary app-server thread wrappers
- workspace backend presenters and arbitrary app-server thread wrappers
Keep domain completion in the consuming app. For example, a pet-game worker can
generate an asset through a flow step, upload the asset, update payment state,
@ -33,7 +33,7 @@ mint if needed, and only then complete the generic run.
This boundary keeps flow packages portable and prevents generic backends from
depending on app-specific Convex schemas, credentials, or release policy.
The Discord gateway backend follows the same rule. It may inspect generic flow
The Discord workspace backend follows the same rule. It may inspect generic flow
runs and events for an operator, but its app-server thread orchestration,
delegation policy, Discord workbench state, and hook-spool wake behavior are not
part of the generic codex-flow backend contract.

View file

@ -1,78 +0,0 @@
---
title: Gateway backend process
description: Process boundaries for local and future remote Codex gateway backends.
---
# Gateway backend process
The current Discord gateway backend runs in-process with the Discord transport.
That keeps deployment simple while preserving the backend boundary in code:
- `DiscordCodexBridge` owns Discord startup, shutdown, command registration, and
inbound dispatch.
- `LocalCodexGatewayBackend` owns app-server connection, Codex thread lifecycle,
turn routing, goals, delegations, workbench state, hook-spool draining, and
persisted gateway state.
- `CodexGatewayPresenter` is the only outbound UI surface the local backend
receives. It can create posts or threads, send and update messages, pin status,
type, and delete presentation artifacts.
## In-process local backend
Local mode is the first implementation. The Discord process constructs a local
backend with:
- a Codex app-server client
- a state store
- bridge configuration
- a presenter adapter backed by the Discord transport
- an optional flow backend client for read-only run and event inspection
The backend may connect to an existing app-server WebSocket or to a local stdio
app-server started by the CLI.
## Browser gateway process
The browser UI talks to the standalone local gateway server instead of talking
directly to the Codex app-server. In development, Vite proxies
`/__codex-gateway` to `codex-gateway-local` on port `3586`.
The browser gateway protocol has two lanes:
| Lane | Methods | Owner |
|------|---------|-------|
| app-server pass-through | `appServer.call`, `appServer.notify`, `appServer.respond`, `appServer.respondError` | Codex app-server |
| gateway-owned | `gateway.*` methods and `gateway.event` notifications | Codex gateway backend |
Native app-server methods stay native. For example, `thread/list`,
`thread/read`, `thread/start`, `turn/start`, `turn/interrupt`,
`account/read`, and app-server-native goal APIs are wrapped in
`appServer.call` and forwarded to the app-server. The gateway may observe,
route, and correlate those calls, but it should not duplicate their semantics.
Gateway-owned methods are for orchestration that the app-server does not own:
delegations, return modes, group wakes, workbench/workspace routing,
hook-spool observed-thread wake behavior, persisted gateway/session state, and
optional read-only flow backend inspection.
## Remote backend
A remote backend can implement the same `CodexGatewayBackend` shape behind HTTP
or WebSocket. The transport-facing protocol should stay small:
| Direction | Shape | Purpose |
|-----------|-------|---------|
| transport to backend | transport-specific inbound events or browser gateway JSON-RPC | lifecycle, commands, and event delivery |
| backend to transport | `CodexGatewayPresenter` operations or `gateway.event` notifications | UI output and presentation updates |
| backend to app-server | Codex app-server client calls | app-server-native thread, turn, auth, goal, and tool behavior |
| backend to flow backend | `@peezy.tech/flow-runtime` backend client calls | optional read-only inspection |
Discord inbound events are still transport-shaped. The browser gateway protocol
is the first transport-neutral client lane; future surfaces should share that
shape where practical and add a presenter adapter only for UI output.
## Flow backend boundary
This gateway backend is not a codex-flow backend. It may inspect flow runs and
events, but it must not own `FlowEvent`, `flow.toml`, `FLOW_RESULT`, matching,
or step execution.

View file

@ -1,40 +0,0 @@
---
title: Gateway backends
description: How Codex gateway surfaces differ from generic flow backends.
---
# Gateway backends
A Codex gateway backend is the runtime behind an operator surface such as
Discord or the browser UI. It owns Codex app-server orchestration and exposes a
small UI-facing contract to the transport.
The Discord bridge is the first transport using this split:
- Discord owns bot login, commands, interactions, Discord channels, and message
delivery.
- The gateway backend owns app-server connection, delegations, workbench state,
hook-spool draining, persisted bridge/session state, and optional flow-run
inspection.
- The local backend is the first implementation. It can connect to an existing
app-server WebSocket or start a local stdio app-server.
The browser UI uses the same split. Its gateway client sends native app-server
methods through `appServer.call`, `appServer.notify`, `appServer.respond`, and
`appServer.respondError`. The gateway forwards those calls instead of
reimplementing app-server behavior for thread list/read, thread start/resume,
turn start/steer/interrupt, auth, account state, and app-server-native goal
APIs.
Gateway-owned commands are reserved for behavior that combines app-server state
with gateway policy or gateway state: delegation, return modes, group wakes,
workbench/workspace routing, hook-spool observed-thread wake behavior, persisted
gateway sessions, and read-only flow backend inspection.
This is separate from codex-flow backends. A flow backend accepts `FlowEvent`,
matches `flow.toml`, executes steps, records `FLOW_RESULT`, and exposes run and
event views. A gateway backend may read those run and event views, but it does
not redefine the flow ABI and should not become the generic flow executor.
Use a gateway backend when the product needs a long-lived Codex control surface.
Use a flow backend when the product needs portable event-driven automation.

View file

@ -0,0 +1,63 @@
---
title: Workspace backend deployments
description: Embedded, networked local, and future remote workspace backend shapes.
---
# Workspace backend deployments
The workspace backend is a capability model first. It can run embedded with a
presenter, as a local networked process, or behind a future remote transport.
## Embedded local
Embedded local mode has no browser-facing HTTP surface and no public socket. A
presenter or transport such as Discord constructs the workspace backend
in-process with:
- an app-server adapter, often a locally spawned `codex app-server` over stdio
- a state store
- workspace configuration
- presenter callbacks for output and UI artifacts
- direct access to delegation, workbench, and flow capabilities
Discord uses this shape today. The Discord wrapper owns bot startup, shutdown,
command registration, and inbound Discord events. The local workspace backend
owns Codex app-server lifecycle, thread routing, goals, delegation, workbench
state, hook-spool draining, flow capability access, and persisted workspace
state.
## Networked local
Networked local mode runs `codex-workspace-backend-local` as one process. It can
connect to an existing app-server or spawn a local stdio app-server, and it can
mount browser/control WebSocket plus flow HTTP surfaces.
In development, the browser can connect through Vite's
`/__codex-workspace-backend` proxy to the local backend on port `3586`.
The browser protocol has two lanes:
| Lane | Methods | Owner |
|------|---------|-------|
| app-server pass-through | `appServer.call`, `appServer.notify`, `appServer.respond`, `appServer.respondError` | Codex app-server adapter |
| workspace-owned | `workspace.*`, `delegation.*`, `flow.*`, and `workspace.event` | Codex workspace backend |
The networked local process also mounts the stable flow HTTP routes such as
`/events`, `/events/:id/replay`, `/runs`, and `/healthz`. Those routes are an
optional transport surface over the same built-in flow capability.
## Future remote
A remote workspace backend should expose the same logical capabilities behind a
remote transport. The transport-facing contract should stay small:
| Direction | Shape | Purpose |
|-----------|-------|---------|
| presenter to backend | transport-specific inbound events or workspace JSON-RPC | lifecycle, commands, and event delivery |
| backend to presenter | presenter operations or `workspace.event` notifications | UI output and presentation updates |
| backend to app-server | app-server adapter calls | app-server-native thread, turn, auth, goal, and tool behavior |
| backend to flow capability | direct calls or mounted HTTP routes | flow dispatch, inspection, and replay |
The backend boundary should not redefine app-server or flow semantics. It owns
workspace orchestration and policy; app-server and flow capabilities keep their
native contracts.

View file

@ -0,0 +1,40 @@
---
title: Workspace backends
description: Logical Codex workspace capabilities across embedded, local, and remote deployments.
---
# Workspace backends
A Codex workspace backend is the logical runtime behind an operator surface such
as Discord, the browser UI, or a CLI. It is not necessarily a network server.
The backend owns shared workspace capabilities and exposes them through the
transport shape that fits the deployment.
The built-in capability families are:
- app-server pass-through for native Codex app-server JSON-RPC methods
- delegation lifecycle, return modes, result flushing, and group wakes
- flow execution and inspection over the generic `FlowEvent`/`FLOW_RESULT` ABI
- workbench state, observed threads, hook-spool returns, and presentation routing
Native app-server methods stay app-server-native. Calls such as `thread/list`,
`thread/read`, `thread/start`, `turn/start`, `turn/interrupt`, `account/read`,
and app-server-native goal APIs are forwarded through the app-server adapter the
workspace backend owns. That adapter might be stdio, a Unix socket, a local
WebSocket, or a future remote transport.
Workspace-owned methods are reserved for behavior that combines app-server
state with workspace policy or workspace state: delegations, return modes, group
wakes, workbench routing, hook-spool observed-thread wake behavior, persisted
workspace sessions, and flow inspection or dispatch.
Flow execution is now a workspace backend capability. In an embedded backend,
tools and presenters can call the flow capability directly. In a networked local
backend, the same capability is also mounted as the existing HTTP routes for
dispatch, inspection, and replay. The generic flow ABI remains unchanged:
products dispatch `FlowEvent`, flow packages match `flow.toml`, steps emit
`FLOW_RESULT`, and app-specific completion stays in the consuming app.
Use a workspace backend when the product needs a long-lived Codex control
surface. Use flow packages when the product needs portable event-driven
automation.

View file

@ -1,26 +1,30 @@
---
title: Operate systemd-local
description: Run the local HTTP backend, inspect stored events, and replay failed runs.
title: Operate the workspace flow backend
description: Run the local workspace backend flow capability and inspect stored events.
---
# Operate systemd-local
# Operate the workspace flow backend
`codex-flow-systemd-local` is the local durable HTTP backend. Patch and other
services can post generic `FlowEvent` JSON to it while operators inspect and
replay runs later.
The local workspace backend includes the durable flow capability. It can accept
generic `FlowEvent` JSON over the networked HTTP surface, persist events and
runs, start matching steps locally, and replay stored events.
In embedded local mode, tools can call the same flow capability directly without
HTTP. The routes below are the optional networked surface mounted by
`codex-workspace-backend-local`.
## Start the backend
```bash
bun run flow:backend serve --cwd /home/peezy/codex-flows-public
bun run workspace:backend --cwd /home/peezy/codex-flows-public
```
Useful environment:
```bash
CODEX_FLOW_BACKEND_HOST=127.0.0.1
CODEX_FLOW_BACKEND_PORT=7345
CODEX_FLOW_BACKEND_DATA_DIR=/var/lib/codex-flow-systemd-local
CODEX_WORKSPACE_BACKEND_HOST=127.0.0.1
CODEX_WORKSPACE_BACKEND_PORT=3586
CODEX_FLOW_BACKEND_DATA_DIR=/var/lib/codex-workspace-flow
CODEX_FLOW_BACKEND_SECRET=shared-hmac-secret
CODEX_FLOW_BACKEND_EXECUTOR=direct
```
@ -32,7 +36,7 @@ suitable when the backend service itself is already managed by systemd.
## Dispatch
```bash
curl -X POST http://127.0.0.1:7345/events \
curl -X POST http://127.0.0.1:3586/events \
-H 'content-type: application/json' \
--data @event.json
```
@ -55,7 +59,7 @@ run attempt.
## Back up state
The backend stores SQLite state under `CODEX_FLOW_BACKEND_DATA_DIR` and per
The capability stores SQLite state under `CODEX_FLOW_BACKEND_DATA_DIR` and per
event JSON files under `CODEX_FLOW_BACKEND_DATA_DIR/events`. Back up the whole
data directory while the service is stopped, or use SQLite online backup plus a
copy of the `events/` directory.

View file

@ -1,12 +1,13 @@
---
title: Run Discord over a local backend
description: Start the Discord bridge with the in-process local Codex gateway backend.
description: Start the Discord bridge with the embedded local Codex workspace backend.
---
# Run Discord over a local backend
Use the local gateway backend when Discord should be the operator surface for a
Codex workspace on the same host.
Use the embedded local workspace backend when Discord should be the operator
surface for a Codex workspace on the same host. This mode does not require a
browser-facing HTTP surface; the bridge constructs the backend in-process.
## 1. Configure Discord access
@ -18,7 +19,7 @@ export CODEX_DISCORD_ALLOWED_USER_IDS="123456789"
export CODEX_DISCORD_ALLOWED_CHANNEL_IDS="987654321"
```
To enable gateway mode, set a home channel:
To enable workspace mode, set a home channel:
```bash
export CODEX_DISCORD_HOME_CHANNEL_ID="987654321"
@ -41,11 +42,12 @@ bun ./apps/discord-bridge/src/index.ts \
--sandbox danger-full-access
```
Both modes use the same in-process `LocalCodexGatewayBackend`.
Both modes use the same in-process `LocalCodexWorkspaceBackend`.
## 3. Add optional workbench channels
Workbench channels let the gateway keep workspace dashboards and task threads:
Workbench channels let the workspace backend keep workspace dashboards and task
threads:
```bash
export CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID="111111111"
@ -56,7 +58,7 @@ Set both values together. A partial workbench configuration is invalid.
## 4. Enable hook-spool returns
Install hooks for the runtime backing the gateway:
Install hooks for the runtime backing the workspace backend:
```bash
codex-discord-bridge hook install
@ -68,12 +70,12 @@ results, and wakes the main operator thread when policy says to.
## 5. Add optional flow inspection
If a codex-flow backend is running, point the gateway at it:
If a workspace flow HTTP surface is running, point the bridge at it:
```bash
export CODEX_FLOW_BACKEND_URL="http://127.0.0.1:8787"
export CODEX_FLOW_BACKEND_URL="http://127.0.0.1:3586"
```
This enables read-only `codex_gateway.list_flow_runs` and
`codex_gateway.list_flow_events`. It does not make the gateway backend a flow
executor.
This enables read-only `codex_workspace.list_flow_runs` and
`codex_workspace.list_flow_events`. Embedded workspace backends can also call a
local flow capability directly without HTTP.

View file

@ -1,52 +0,0 @@
---
title: Run web over the local gateway
description: Start the browser UI through codex-gateway-local instead of a direct app-server WebSocket.
---
# Run web over the local gateway
Use the local gateway when the browser UI should share the same backend boundary
as Discord: the UI is a presenter/client, the gateway owns orchestration, and
the Codex app-server remains the source of truth for native app-server methods.
## Start the gateway
Connect the gateway to an existing app-server WebSocket:
```sh
bun apps/gateway/src/index.ts serve --app-server-url ws://127.0.0.1:3585
```
Or let the gateway start a local stdio app-server:
```sh
bun apps/gateway/src/index.ts serve --local-app-server
```
The gateway listens on `ws://127.0.0.1:3586` by default. Override it with
`--host`, `--port`, `CODEX_GATEWAY_HOST`, or `CODEX_GATEWAY_PORT`.
## Start the browser UI
```sh
bun run dev:web
```
The Vite dev server proxies `ws://<web-host>/__codex-gateway` to
`ws://127.0.0.1:3586`. Set `VITE_CODEX_GATEWAY_PROXY_TARGET` if the gateway is
on another host or port.
For a browser that should connect directly to a gateway WebSocket instead of
using the dev proxy, set `VITE_CODEX_GATEWAY_WS_URL`.
## Boundary
The web client uses `CodexGatewayClient`. Native app-server operations such as
thread listing, thread reads, thread starts, turn starts, turn interrupts, auth,
and account reads are sent through `appServer.call` and forwarded by the
gateway.
Do not reimplement app-server behavior in the gateway just to serve the web UI.
Add gateway-owned methods only for behavior that combines app-server state with
gateway state or policy, such as delegations, workbench routing, hook-spool
wakes, persisted gateway sessions, or read-only flow backend inspection.

View file

@ -0,0 +1,54 @@
---
title: Run web over the local workspace backend
description: Start the browser UI through codex-workspace-backend-local.
---
# Run web over the local workspace backend
Use the networked local workspace backend when the browser UI should share the
same backend boundary as other presenters: the UI is a client, the workspace
backend owns orchestration, and the Codex app-server remains the source of truth
for native app-server methods.
## Start the backend
Connect the workspace backend to an existing app-server WebSocket:
```sh
bun apps/workspace-backend/src/index.ts serve --app-server-url ws://127.0.0.1:3585
```
Or let it start a local stdio app-server:
```sh
bun apps/workspace-backend/src/index.ts serve --local-app-server
```
The backend listens on `ws://127.0.0.1:3586` by default. Override it with
`--host`, `--port`, `CODEX_WORKSPACE_BACKEND_HOST`, or
`CODEX_WORKSPACE_BACKEND_PORT`.
## Start the browser UI
```sh
bun run dev:web
```
The Vite dev server proxies `ws://<web-host>/__codex-workspace-backend` to
`ws://127.0.0.1:3586`. Set `VITE_CODEX_WORKSPACE_BACKEND_PROXY_TARGET` if the
backend is on another host or port.
For a browser that should connect directly to a workspace backend WebSocket
instead of using the dev proxy, set `VITE_CODEX_WORKSPACE_BACKEND_WS_URL`.
## Boundary
The web client uses `CodexWorkspaceBackendClient`. Native app-server operations
such as thread listing, thread reads, thread starts, turn starts, turn
interrupts, auth, and account reads are sent through `appServer.call` and
forwarded by the workspace backend's app-server adapter.
Do not reimplement app-server behavior in the workspace backend just to serve
the web UI. Add workspace-owned methods only for behavior that combines
app-server state with workspace state or policy, such as delegations, workbench
routing, hook-spool wakes, persisted workspace sessions, or flow inspection.

View file

@ -36,7 +36,7 @@ flowchart LR
- New to flows: [Build your first flow](tutorials/first-flow).
- Integrating a product: [Dispatch a release event](tutorials/dispatch-release-event).
- Need exact shapes: [FlowEvent and FLOW_RESULT](reference/flow-event).
- Operating runs: [Operate systemd-local](guides/operate-systemd-local).
- Operating runs: [Operate the workspace flow backend](guides/operate-workspace-flow-backend).
## What is in this repo
@ -47,7 +47,7 @@ flowchart LR
- `@peezy.tech/flow-backend-convex`: reusable Convex control-plane component
for generic flow events and runs.
- `codex-flow-runner`: CLI for discovering and firing local flow packages.
- `codex-flow-systemd-local`: local HTTP backend for durable flow dispatch,
inspection, replay, and optional `systemd-run` execution.
- `codex-workspace-backend-local`: local workspace backend process with durable
flow dispatch, inspection, replay, and optional `systemd-run` execution.
- `codex-discord-bridge`: Discord sidecar for routing Discord threads to Codex
app-server threads, gateway delegation, and read-only flow inspection.
app-server threads, workspace delegation, and flow inspection.

View file

@ -1,12 +1,13 @@
---
title: Backend HTTP API
description: Endpoints used by systemd-local and compatible flow backends.
description: Endpoints used by the workspace flow HTTP surface and compatible flow backends.
---
# Backend HTTP API
HTTP backends accept generic `FlowEvent` objects and expose stored event and run
state.
state. In the local workspace backend, these routes are an optional networked
surface over the built-in flow capability.
| Method | Path | Purpose |
|--------|------|---------|
@ -29,8 +30,8 @@ SHA-256 and send:
x-flow-signature-256: sha256=<hex digest>
```
`x-patch-flow-signature-256` remains accepted by systemd-local for older Patch
dispatchers.
`x-patch-flow-signature-256` remains accepted by the local workspace backend for
older Patch dispatchers.
## Compatibility

View file

@ -16,7 +16,7 @@ bun run flow run <flow> <step> --event event.json
`flow fire` dispatches through the local client and runs every step whose
trigger type and schema match the event.
## systemd-local backend
## Workspace flow backend
```bash
bun run flow:backend serve --cwd <workspace>
@ -34,6 +34,6 @@ bun run flow:backend replay-event <event-id> --wait
| `CODEX_FLOWS_MODE=code-mode` | Enables Code Mode flow steps and Peezy Codex defaults. |
| `CODEX_APP_SERVER_CODEX_COMMAND` | Overrides the Codex command for stdio app-server launches. |
| `CODEX_FLOW_BACKEND_URL` | HTTP backend URL for consumers such as Discord bridge inspection. |
| `CODEX_FLOW_BACKEND_SECRET` | Shared HMAC secret for systemd-local dispatch. |
| `CODEX_FLOW_BACKEND_SECRET` | Shared HMAC secret for HTTP flow dispatch. |
| `CODEX_FLOW_BACKEND_EXECUTOR` | `direct` or `systemd-run`. |
| `CODEX_FLOW_BACKEND_DATA_DIR` | Durable backend state directory. |

View file

@ -1,21 +1,21 @@
---
title: Discord bridge
description: Long-lived Discord sidecar for Codex app-server threads, gateway mode, and flow inspection.
description: Long-lived Discord sidecar for Codex app-server threads, workspace mode, and flow inspection.
---
# Discord bridge
`codex-discord-bridge` is a private workspace app that exposes Discord as a
transport over a Codex gateway backend. It is a user interface and operator
transport over a Codex workspace backend. It is a user interface and operator
sidecar, not part of the generic flow runtime.
Use it when a team wants to:
- start or resume Codex work from Discord
- keep one gateway home channel as the operator surface
- keep one workspace home channel as the operator surface
- delegate work into separate Codex threads
- expose selected Codex thread state through Discord commands
- inspect codex-flow backend events and runs from the gateway
- inspect workspace flow backend events and runs
## Run it
@ -47,24 +47,23 @@ Common optional configuration:
|----------|---------|
| `CODEX_DISCORD_ALLOWED_CHANNEL_IDS` | Parent channels where the bridge may respond. |
| `CODEX_DISCORD_DIR` | Root directory for Codex thread workspaces. |
| `CODEX_DISCORD_HOME_CHANNEL_ID` | Enables gateway mode for a Discord home channel. |
| `CODEX_DISCORD_MAIN_THREAD_ID` | Existing Codex operator thread to resume for gateway mode. |
| `CODEX_DISCORD_HOME_CHANNEL_ID` | Enables workspace mode for a Discord home channel. |
| `CODEX_DISCORD_MAIN_THREAD_ID` | Existing Codex operator thread to resume for workspace mode. |
| `CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID` | Optional workbench forum channel. |
| `CODEX_DISCORD_TASK_THREADS_CHANNEL_ID` | Optional workbench task-thread channel. |
| `CODEX_FLOW_BACKEND_URL` | Optional HTTP flow backend for read-only run/event inspection. |
| `CODEX_FLOW_BACKEND_URL` | Optional workspace flow HTTP surface for run/event inspection. |
| `CODEX_DISCORD_HOOK_SPOOL_DIR` | Directory for Codex hook lifecycle events. |
## Gateway mode
## Workspace Mode
Gateway mode keeps one Discord home channel as the compact operator surface and
Workspace mode keeps one Discord home channel as the compact operator surface and
one Codex main thread as the model-visible operator memory. Normal messages in
the home channel go to that main thread. The main thread receives privileged
`codex_gateway` tools that can start, resume, read, and message delegated Codex
`codex_workspace` tools that can start, resume, read, and message delegated Codex
sessions.
The gateway tools can also list flow events and runs when
`CODEX_FLOW_BACKEND_URL` points at a compatible backend such as
`codex-flow-systemd-local`.
The workspace tools can also list flow events and runs when
`CODEX_FLOW_BACKEND_URL` points at a compatible workspace flow HTTP surface.
## Backend contract
@ -73,12 +72,12 @@ The Discord process has two sides:
- the Discord transport starts the bot, receives commands and messages, maps
Discord channels and threads, registers slash commands, and sends Discord
output
- the gateway backend handles inbound events, owns Codex app-server lifecycle,
- the workspace backend handles inbound events, owns Codex app-server lifecycle,
starts and resumes Codex threads, manages goals, delegations, workbench state,
persisted bridge state, and hook-spool wake behavior
The built-in backend is local. It preserves the current behavior while giving
the bridge an explicit `CodexGatewayBackend` contract that another backend can
the bridge an explicit `CodexWorkspaceBackend` contract that another backend can
implement later. The local backend only receives the outbound Discord
presentation surface; transport startup, shutdown, inbound dispatch, and command
registration stay in the Discord wrapper.
@ -102,7 +101,7 @@ Single-surface `.env` configuration is the default. For multi-guild routing,
put one surface entry in a workspace-owned `.codex/workspace.toml`:
```toml
[[discord.gateway.surfaces]]
[[discord.workspace.surfaces]]
key = "crypto"
home_channel_id = "1503107617512919220"
workspace_forum_channel_id = "1503107617512919221"
@ -115,7 +114,7 @@ workspace is the route.
## Codex hooks
Install Codex hooks once for the runtime backing the gateway:
Install Codex hooks once for the runtime backing the workspace backend:
```bash
codex-discord-bridge hook install
@ -136,8 +135,8 @@ operator thread when configured.
## Boundary
The Discord bridge may present flow backend events and runs, but it does not
own the generic flow ABI. The gateway backend can read from
`@peezy.tech/flow-runtime` backend clients for inspection, but flow packages
still communicate through `FlowEvent`, `flow.toml`, and `FLOW_RESULT`;
app-specific completion still belongs in the app that dispatched or consumed the
event.
own the generic flow ABI. The workspace backend can read from
`@peezy.tech/flow-runtime` backend clients or the built-in workspace flow
capability for inspection, but flow packages still communicate through
`FlowEvent`, `flow.toml`, and `FLOW_RESULT`; app-specific completion still
belongs in the app that dispatched or consumed the event.

View file

@ -10,7 +10,7 @@ description: Public and workspace packages in the codex-flow stack.
Low-level Codex app-server client package. It exports:
- app-server JSON-RPC client and stdio/WebSocket transports
- browser-safe gateway client and gateway protocol server primitives
- browser-safe workspace backend client and protocol server primitives
- browser-safe WebSocket transport
- framework-agnostic app-server flow helpers
- auth helpers for account login/status/usage
@ -36,10 +36,9 @@ attempts, leases, output chunks, and final result payloads.
## Workspace apps
- `codex-flow-runner`: local CLI for listing, firing, and running steps.
- `codex-flow-systemd-local`: local durable HTTP backend and CLI.
- `codex-workspace-backend-local`: local workspace backend process with browser
control WebSocket and optional flow HTTP routes.
- [`codex-discord-bridge`](discord-bridge): Discord-to-Codex bridge with
gateway delegation and read-only flow inspection tools.
- `codex-gateway-local`: local WebSocket gateway that forwards native
app-server calls and exposes gateway-owned events/commands.
- `web`: browser UI for Codex threads through the local gateway.
workspace delegation and flow inspection tools.
- `web`: browser UI for Codex threads through the local workspace backend.
- `codex-app-cli`: JSON-RPC CLI for app-server actions.

View file

@ -22,12 +22,12 @@ export default {
"guides/author-flow-package",
"guides/run-flows-locally",
"guides/dispatch-and-replay-events",
"guides/operate-systemd-local",
"guides/operate-workspace-flow-backend",
"guides/use-convex-backend",
"guides/enable-code-mode",
"guides/operate-codex-release-flows",
"guides/run-discord-local-backend",
"guides/run-web-over-local-gateway",
"guides/run-web-over-local-workspace-backend",
],
},
{
@ -48,8 +48,8 @@ export default {
"concepts/architecture",
"concepts/backends",
"concepts/domain-boundaries",
"concepts/gateway-backends",
"concepts/gateway-backend-process",
"concepts/workspace-backends",
"concepts/workspace-backend-deployments",
"concepts/code-mode",
],
},

View file

@ -37,7 +37,7 @@
}
},
"scripts": {
"build": "bun run --filter @peezy.tech/codex-flows build && bun run --filter @peezy.tech/flow-runtime build && bun run --filter @peezy.tech/flow-backend-convex build && bun run --filter @peezy.tech/codex-opencode-go-router build && bun run --filter @workspace/ui build && bun run --filter codex-app-cli build && bun run --filter codex-discord-bridge build && bun run --filter codex-gateway-local build && bun run --filter codex-flow-systemd-local build && bun run --filter codex-flow-runner build && bun run --filter web build && bun run --filter @peezy.tech/codex-flow-docs build",
"build": "bun run --filter @peezy.tech/codex-flows build && bun run --filter @peezy.tech/flow-runtime build && bun run --filter @peezy.tech/flow-backend-convex build && bun run --filter @peezy.tech/codex-opencode-go-router build && bun run --filter @workspace/ui build && bun run --filter codex-app-cli build && bun run --filter codex-discord-bridge build && bun run --filter codex-workspace-backend-local build && bun run --filter codex-flow-runner build && bun run --filter web build && bun run --filter @peezy.tech/codex-flow-docs build",
"check:types": "bun run --workspaces check:types",
"codex:update": "bun scripts/run-codex-release-update-thread.ts",
"dev": "bun run --filter web dev",
@ -46,12 +46,12 @@
"docs:dev": "bun run --filter @peezy.tech/codex-flow-docs dev",
"dev:web": "bun run --filter web dev",
"flow": "bun apps/flow-runner/src/index.ts",
"flow:backend": "bun apps/flow-backend-systemd-local/src/index.ts",
"gateway": "bun apps/gateway/src/index.ts serve",
"flow:backend": "bun apps/workspace-backend/src/index.ts",
"workspace:backend": "bun apps/workspace-backend/src/index.ts serve",
"replay:thread": "bun scripts/run-code-mode-in-new-thread.ts",
"start": "bun run --filter web preview",
"start:discord:debug:commentary": "bun run --filter codex-discord-bridge start:debug:commentary",
"test": "bun run --filter @peezy.tech/codex-flows test && bun run --filter @peezy.tech/flow-runtime test && bun run --filter @peezy.tech/flow-backend-convex test && bun run --filter @peezy.tech/codex-opencode-go-router test && bun run --filter codex-flow-systemd-local test && bun run --filter codex-flow-runner test && bun run --filter codex-app-cli test && bun run --filter codex-discord-bridge test && bun run --filter codex-gateway-local test && bun run --filter web test",
"test": "bun run --filter @peezy.tech/codex-flows test && bun run --filter @peezy.tech/flow-runtime test && bun run --filter @peezy.tech/flow-backend-convex test && bun run --filter @peezy.tech/codex-opencode-go-router test && bun run --filter codex-workspace-backend-local test && bun run --filter codex-flow-runner test && bun run --filter codex-app-cli test && bun run --filter codex-discord-bridge test && bun run --filter web test",
"release:check": "bun run --filter @peezy.tech/codex-flows release:check && bun run --filter @peezy.tech/flow-runtime release:check && bun run --filter @peezy.tech/flow-backend-convex release:check"
}
}

View file

@ -45,9 +45,9 @@
"types": "./dist/workbench.d.ts",
"import": "./dist/workbench.js"
},
"./gateway": {
"types": "./dist/gateway/index.d.ts",
"import": "./dist/gateway/index.js"
"./workspace-backend": {
"types": "./dist/workspace-backend/index.d.ts",
"import": "./dist/workspace-backend/index.js"
},
"./rpc": {
"types": "./dist/app-server/rpc.d.ts",

View file

@ -4,11 +4,11 @@ export {
type CodexBrowserAppServerTransport as CodexAppServerTransport,
} from "./app-server/browser-client.ts";
export {
CodexGatewayClient,
type CodexGatewayClientOptions,
type CodexGatewayTransport,
type GatewayEvent,
} from "./gateway/client.ts";
CodexWorkspaceBackendClient,
type CodexWorkspaceBackendClientOptions,
type CodexWorkspaceBackendTransport,
type WorkspaceBackendEvent,
} from "./workspace-backend/client.ts";
export {
CodexWebSocketTransport,
type CodexWebSocketTransportOptions,

View file

@ -1,40 +0,0 @@
export {
CodexGatewayClient,
type CodexGatewayClientOptions,
type CodexGatewayTransport,
type GatewayEvent,
} from "./client.ts";
export {
CodexGatewayProtocolServer,
type CodexGatewayAppServer,
type CodexGatewayPeer,
type CodexGatewayProtocolServerOptions,
} from "./server.ts";
export {
APP_SERVER_CALL_METHOD,
APP_SERVER_NOTIFICATION_METHOD,
APP_SERVER_NOTIFY_METHOD,
APP_SERVER_REQUEST_METHOD,
APP_SERVER_RESPOND_ERROR_METHOD,
APP_SERVER_RESPOND_METHOD,
GATEWAY_EVENT_METHOD,
GATEWAY_INITIALIZE_METHOD,
appServerCallParams,
appServerNotificationParams,
appServerNotifyParams,
appServerRequestParams,
appServerRespondErrorParams,
appServerRespondParams,
gatewayEventParams,
gatewayOwnedMethodPrefixes,
isGatewayOwnedMethod,
type AppServerCallParams,
type AppServerNotificationParams,
type AppServerNotifyParams,
type AppServerRequestParams,
type AppServerRespondErrorParams,
type AppServerRespondParams,
type GatewayEventParams,
type GatewayInitializeParams,
type GatewayInitializeResponse,
} from "./protocol.ts";

View file

@ -4,15 +4,15 @@ export {
type CodexAppServerTransport,
} from "./app-server/client.ts";
export {
CodexGatewayClient,
CodexGatewayProtocolServer,
type CodexGatewayAppServer,
type CodexGatewayClientOptions,
type CodexGatewayPeer,
type CodexGatewayProtocolServerOptions,
type CodexGatewayTransport,
type GatewayEvent,
} from "./gateway/index.ts";
CodexWorkspaceBackendClient,
CodexWorkspaceBackendProtocolServer,
type CodexWorkspaceBackendAppServer,
type CodexWorkspaceBackendClientOptions,
type CodexWorkspaceBackendPeer,
type CodexWorkspaceBackendProtocolServerOptions,
type CodexWorkspaceBackendTransport,
type WorkspaceBackendEvent,
} from "./workspace-backend/index.ts";
export {
CodexStdioTransport,
DEFAULT_CODEX_COMMAND,

View file

@ -12,16 +12,16 @@ import {
APP_SERVER_REQUEST_METHOD,
APP_SERVER_RESPOND_ERROR_METHOD,
APP_SERVER_RESPOND_METHOD,
GATEWAY_EVENT_METHOD,
GATEWAY_INITIALIZE_METHOD,
WORKSPACE_BACKEND_EVENT_METHOD,
WORKSPACE_BACKEND_INITIALIZE_METHOD,
appServerNotificationParams,
appServerRequestParams,
gatewayEventParams,
type GatewayEvent,
type GatewayInitializeResponse,
workspaceBackendEventParams,
type WorkspaceBackendEvent,
type WorkspaceBackendInitializeResponse,
} from "./protocol.ts";
export type CodexGatewayTransport = CodexEventEmitter & {
export type CodexWorkspaceBackendTransport = CodexEventEmitter & {
readonly requestTimeoutMs: number;
start(): void;
close(): void;
@ -29,26 +29,26 @@ export type CodexGatewayTransport = CodexEventEmitter & {
notify(method: string, params?: unknown): void;
};
export type CodexGatewayClientOptions = {
transport?: CodexGatewayTransport;
export type CodexWorkspaceBackendClientOptions = {
transport?: CodexWorkspaceBackendTransport;
webSocketTransportOptions?: CodexWebSocketTransportOptions;
clientName?: string;
clientTitle?: string;
clientVersion?: string;
};
export class CodexGatewayClient extends CodexEventEmitter {
readonly transport: CodexGatewayTransport;
export class CodexWorkspaceBackendClient extends CodexEventEmitter {
readonly transport: CodexWorkspaceBackendTransport;
#clientName: string;
#clientTitle: string | null;
#clientVersion: string;
#connected = false;
constructor(options: CodexGatewayClientOptions = {}) {
constructor(options: CodexWorkspaceBackendClientOptions = {}) {
super();
const url = options.webSocketTransportOptions?.url;
if (!options.transport && !url) {
throw new Error("A Codex gateway WebSocket URL is required");
throw new Error("A Codex workspace backend WebSocket URL is required");
}
this.transport =
options.transport ??
@ -56,8 +56,8 @@ export class CodexGatewayClient extends CodexEventEmitter {
url: url!,
requestTimeoutMs: options.webSocketTransportOptions?.requestTimeoutMs,
});
this.#clientName = options.clientName ?? "codex-gateway-client";
this.#clientTitle = options.clientTitle ?? "Codex Gateway Client";
this.#clientName = options.clientName ?? "codex-workspace-backend-client";
this.#clientTitle = options.clientTitle ?? "Codex Workspace Backend Client";
this.#clientVersion = options.clientVersion ?? "0.1.0";
this.transport.on("notification", (message) => {
@ -75,10 +75,10 @@ export class CodexGatewayClient extends CodexEventEmitter {
}
return;
}
if (message.method === GATEWAY_EVENT_METHOD) {
const params = gatewayEventParams(message.params);
if (message.method === WORKSPACE_BACKEND_EVENT_METHOD) {
const params = workspaceBackendEventParams(message.params);
if (params) {
this.emit("gatewayEvent", params.event);
this.emit("workspaceBackendEvent", params.event);
}
return;
}
@ -93,8 +93,8 @@ export class CodexGatewayClient extends CodexEventEmitter {
return;
}
this.transport.start();
await this.transport.request<GatewayInitializeResponse>(
GATEWAY_INITIALIZE_METHOD,
await this.transport.request<WorkspaceBackendInitializeResponse>(
WORKSPACE_BACKEND_INITIALIZE_METHOD,
{
clientInfo: {
name: this.#clientName,
@ -136,7 +136,7 @@ export class CodexGatewayClient extends CodexEventEmitter {
}).catch((error: unknown) => this.emit("error", error));
}
gatewayRequest<T = unknown>(method: string, params?: unknown): Promise<T> {
workspaceRequest<T = unknown>(method: string, params?: unknown): Promise<T> {
return this.transport.request<T>(method, params);
}
@ -187,4 +187,4 @@ export class CodexGatewayClient extends CodexEventEmitter {
}
}
export type { GatewayEvent };
export type { WorkspaceBackendEvent };

View file

@ -0,0 +1,491 @@
import type { v2 } from "../app-server/generated/index.ts";
export type WorkspaceDelegationReturnMode =
| "detached"
| "record_only"
| "wake_on_done"
| "wake_on_group"
| "manual";
export type WorkspaceDelegationStatus =
| "active"
| "idle"
| "failed"
| "complete"
| "reported";
export type WorkspaceDelegation = {
id: string;
codexThreadId: string;
title: string;
status: WorkspaceDelegationStatus;
cwd?: string;
workspaceKey?: string;
groupId?: string;
returnMode?: WorkspaceDelegationReturnMode;
metadata?: Record<string, unknown>;
lastTurnId?: string;
lastStatus?: string;
lastFinal?: string;
completedAt?: string;
injectedAt?: string;
mirroredAt?: string;
taskMirroredAt?: string;
reportedAt?: string;
createdAt: string;
updatedAt: string;
};
export type WorkspacePendingWake = {
id: string;
kind: "delegation" | "group";
delegationIds: string[];
groupId?: string;
reason: string;
createdAt: string;
startedAt?: string;
};
export type WorkspaceDelegationAppServer = {
startThread(params: v2.ThreadStartParams): Promise<v2.ThreadStartResponse>;
resumeThread(params: v2.ThreadResumeParams): Promise<v2.ThreadResumeResponse>;
setThreadName(params: v2.ThreadSetNameParams): Promise<v2.ThreadSetNameResponse>;
startTurn(params: v2.TurnStartParams): Promise<v2.TurnStartResponse>;
readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse>;
};
export type WorkspaceDelegationState = {
delegations: WorkspaceDelegation[];
pendingWakes?: WorkspacePendingWake[];
};
export type WorkspaceDelegationCapabilityOptions = {
client: WorkspaceDelegationAppServer;
state: WorkspaceDelegationState;
now?: () => Date;
threadStartParams(cwd: string): v2.ThreadStartParams;
threadResumeParams(threadId: string, cwd?: string): v2.ThreadResumeParams;
turnStartParams(input: {
threadId: string;
prompt: string;
cwd?: string | null;
}): v2.TurnStartParams;
metadataFromArgs?: (args: Record<string, unknown>) => Record<string, unknown> | undefined;
surfaceKeyForCwd?: (cwd?: string) => string | undefined;
recordResult?: (delegation: WorkspaceDelegation) => Promise<void>;
mirrorResult?: (delegation: WorkspaceDelegation) => Promise<void>;
enqueueWake?: (input: {
kind: WorkspacePendingWake["kind"];
delegationIds: string[];
groupId?: string;
reason: string;
}) => void;
processPendingWakes?: () => Promise<boolean>;
};
export class WorkspaceDelegationCapability {
#client: WorkspaceDelegationAppServer;
#state: WorkspaceDelegationState;
#now: () => Date;
#options: WorkspaceDelegationCapabilityOptions;
constructor(options: WorkspaceDelegationCapabilityOptions) {
this.#client = options.client;
this.#state = options.state;
this.#now = options.now ?? (() => new Date());
this.#options = options;
}
list(): { delegations: WorkspaceDelegation[] } {
return { delegations: this.#delegations() };
}
async start(args: Record<string, unknown>): Promise<{
delegation: WorkspaceDelegation;
turnId?: string;
}> {
const cwd = requiredArg(args, "cwd");
const title = stringValue(args.title) ?? firstLine(stringValue(args.prompt)) ??
`Delegated ${compactId(cwd)}`;
const prompt = stringValue(args.prompt);
const groupId = stringValue(args.groupId);
const returnMode = returnModeFromArgs(
args,
groupId ? "wake_on_group" : "wake_on_done",
);
const started = await this.#client.startThread(this.#options.threadStartParams(cwd));
const codexThreadId = started.thread.id;
await this.#client.setThreadName({
threadId: codexThreadId,
name: `[delegated] ${title}`,
});
const now = this.#now().toISOString();
const delegation = this.upsert({
id: delegationId(codexThreadId),
codexThreadId,
title,
status: prompt ? "active" : "idle",
cwd,
groupId,
returnMode,
metadata: this.#options.metadataFromArgs?.(args),
createdAt: now,
updatedAt: now,
});
let turnId: string | undefined;
if (prompt) {
const turn = await this.#client.startTurn(this.#options.turnStartParams({
threadId: codexThreadId,
prompt,
cwd,
}));
turnId = turn.turn.id;
delegation.lastTurnId = turnId;
}
return { delegation, turnId };
}
async resume(args: Record<string, unknown>): Promise<{ delegation: WorkspaceDelegation }> {
const codexThreadId = requiredArg(args, "threadId");
const cwd = stringValue(args.cwd);
const groupId = stringValue(args.groupId);
const resumed = await this.#client.resumeThread(
this.#options.threadResumeParams(codexThreadId, cwd),
);
const now = this.#now().toISOString();
const resolvedCwd = cwd ?? resumeResponseCwd(resumed);
const existing = this.delegationForThread(codexThreadId);
const delegation = this.upsert({
id: stringValue(args.id) ?? delegationId(codexThreadId),
codexThreadId,
title: stringValue(args.title) ?? `Delegated ${compactId(codexThreadId)}`,
status: "idle",
cwd: resolvedCwd,
groupId,
returnMode: returnModeFromArgs(args, "manual"),
metadata: this.#options.metadataFromArgs?.(args),
createdAt: existing?.createdAt ?? now,
updatedAt: now,
});
return { delegation };
}
async send(args: Record<string, unknown>): Promise<{
delegation: WorkspaceDelegation;
turnId: string;
}> {
const delegation = this.requireDelegation(args);
const prompt = requiredArg(args, "prompt");
const groupId = stringValue(args.groupId);
if (groupId) {
delegation.groupId = groupId;
}
delegation.returnMode = returnModeFromArgs(
args,
delegation.returnMode ?? (delegation.groupId ? "wake_on_group" : "wake_on_done"),
);
const turn = await this.#client.startTurn(this.#options.turnStartParams({
threadId: delegation.codexThreadId,
prompt,
cwd: delegation.cwd ?? null,
}));
delegation.status = "active";
delegation.lastTurnId = turn.turn.id;
delegation.lastStatus = undefined;
delegation.lastFinal = undefined;
delegation.completedAt = undefined;
delegation.injectedAt = undefined;
delegation.mirroredAt = undefined;
delegation.taskMirroredAt = undefined;
delegation.reportedAt = undefined;
delegation.updatedAt = this.#now().toISOString();
return { delegation, turnId: turn.turn.id };
}
async read(args: Record<string, unknown>): Promise<{
delegation: WorkspaceDelegation;
latestTurnId?: string;
latestStatus?: string;
lastFinal?: ThreadSnapshot["lastFinal"];
terminalTurnIds: string[];
}> {
const delegation = this.requireDelegation(args);
const response = await this.#client.readThread({
threadId: delegation.codexThreadId,
includeTurns: true,
});
const snapshot = threadSnapshotFromThread(response.thread);
const turns = Array.isArray(response.thread.turns) ? response.thread.turns : [];
const latest = record(turns[turns.length - 1]);
const latestStatus = stringValue(latest.status);
if (latestStatus === "completed") {
delegation.status = "complete";
} else if (latestStatus === "failed" || latestStatus === "interrupted") {
delegation.status = "failed";
} else if (latestStatus) {
delegation.status = "active";
}
delegation.lastTurnId = stringValue(latest.id) ?? delegation.lastTurnId;
delegation.lastStatus = latestStatus ?? delegation.lastStatus;
delegation.lastFinal = snapshot.lastFinal?.text ?? delegation.lastFinal;
if (latestStatus && isTerminalTurnStatus(latestStatus)) {
delegation.completedAt ??= this.#now().toISOString();
}
delegation.updatedAt = this.#now().toISOString();
return {
delegation,
latestTurnId: stringValue(latest.id),
latestStatus,
lastFinal: snapshot.lastFinal,
terminalTurnIds: snapshot.terminalTurnIds,
};
}
setPolicy(args: Record<string, unknown>): { delegations: WorkspaceDelegation[] } {
const groupId = stringValue(args.groupId);
const mode = returnModeFromArgs(args, undefined);
if (!mode) {
throw new Error("Missing required argument: returnMode");
}
const delegations = groupId
? this.#delegations().filter((delegation) => delegation.groupId === groupId)
: [this.requireDelegation(args)];
if (delegations.length === 0) {
throw new Error("No matching workspace delegations.");
}
const now = this.#now().toISOString();
for (const delegation of delegations) {
delegation.returnMode = mode;
delegation.updatedAt = now;
}
return { delegations };
}
async flushResults(args: Record<string, unknown>): Promise<{ flushed: WorkspaceDelegation[] }> {
const groupId = stringValue(args.groupId);
const delegations = groupId
? this.#delegations().filter((delegation) => delegation.groupId === groupId)
: stringValue(args.delegationId) || stringValue(args.threadId) || stringValue(args.id)
? [this.requireDelegation(args)]
: this.#delegations();
const flushed: WorkspaceDelegation[] = [];
for (const delegation of delegations) {
if (!isTerminalDelegation(delegation)) {
continue;
}
await this.#options.recordResult?.(delegation);
await this.#options.mirrorResult?.(delegation);
flushed.push(delegation);
}
if (flushed.length > 0 && stringValue(args.wake) !== "false") {
this.#options.enqueueWake?.({
kind: groupId ? "group" : "delegation",
groupId,
delegationIds: flushed.map((delegation) => delegation.id),
reason: groupId
? `Delegation group ${groupId} was manually flushed.`
: "Delegation results were manually flushed.",
});
await this.#options.processPendingWakes?.();
}
return { flushed };
}
listGroups(): Array<{
groupId: string;
total: number;
active: number;
terminal: number;
pendingWake: boolean;
}> {
const groups = new Map<string, WorkspaceDelegation[]>();
for (const delegation of this.#delegations()) {
if (!delegation.groupId) {
continue;
}
const existing = groups.get(delegation.groupId) ?? [];
existing.push(delegation);
groups.set(delegation.groupId, existing);
}
return [...groups.entries()].map(([groupId, delegations]) => ({
groupId,
total: delegations.length,
active: delegations.filter((delegation) => delegation.status === "active").length,
terminal: delegations.filter(isTerminalDelegation).length,
pendingWake: (this.#state.pendingWakes ?? []).some((wake) =>
wake.groupId === groupId && !wake.startedAt
),
}));
}
upsert(input: WorkspaceDelegation): WorkspaceDelegation {
const delegations = this.#delegations();
const index = delegations.findIndex((delegation) =>
delegation.id === input.id ||
delegation.codexThreadId === input.codexThreadId
);
if (index >= 0) {
delegations[index] = { ...delegations[index], ...input };
return delegations[index] as WorkspaceDelegation;
}
delegations.push(input);
return input;
}
requireDelegation(args: Record<string, unknown>): WorkspaceDelegation {
const id = stringValue(args.delegationId) ?? stringValue(args.id);
const threadId = stringValue(args.threadId);
const delegation = this.#delegations().find((candidate) =>
(id && candidate.id === id) ||
(threadId && candidate.codexThreadId === threadId)
);
if (!delegation) {
throw new Error("Unknown workspace delegation.");
}
return delegation;
}
delegationForThread(threadId: string): WorkspaceDelegation | undefined {
return this.#delegations().find((delegation) =>
delegation.codexThreadId === threadId
);
}
#delegations(): WorkspaceDelegation[] {
this.#state.delegations ??= [];
return this.#state.delegations;
}
}
type ThreadSnapshot = {
terminalTurnIds: string[];
lastFinal?: {
turnId: string;
text: string;
};
};
export function workspaceDelegationId(threadId: string): string {
return delegationId(threadId);
}
export function returnModeFromArgs(
args: Record<string, unknown>,
fallback: WorkspaceDelegationReturnMode | undefined,
): WorkspaceDelegationReturnMode | undefined {
const value = stringValue(args.returnMode) ?? stringValue(args.returnPolicy);
if (!value) {
return fallback;
}
if (value === "immediate") {
return "wake_on_done";
}
if (value === "group_barrier") {
return "wake_on_group";
}
if (
value === "detached" ||
value === "record_only" ||
value === "wake_on_done" ||
value === "wake_on_group" ||
value === "manual"
) {
return value;
}
throw new Error(`Invalid returnMode: ${value}`);
}
export function isTerminalDelegation(delegation: WorkspaceDelegation): boolean {
return delegation.status === "complete" ||
delegation.status === "failed" ||
delegation.status === "reported";
}
function threadSnapshotFromThread(thread: { turns?: unknown[] }): ThreadSnapshot {
const turns = Array.isArray(thread.turns) ? thread.turns : [];
const terminalTurnIds: string[] = [];
let lastFinal: ThreadSnapshot["lastFinal"];
for (const turn of turns) {
const parsed = record(turn);
const turnId = stringValue(parsed.id);
if (turnId && isTerminalTurnStatus(parsed.status)) {
terminalTurnIds.push(turnId);
}
}
for (const turn of [...turns].reverse()) {
const parsed = record(turn);
const turnId = stringValue(parsed.id);
const text = lastFinalTextFromTurn(parsed);
if (turnId && text) {
lastFinal = { turnId, text };
break;
}
}
if (lastFinal && !terminalTurnIds.includes(lastFinal.turnId)) {
terminalTurnIds.push(lastFinal.turnId);
}
return {
terminalTurnIds: [...new Set(terminalTurnIds)],
lastFinal,
};
}
function resumeResponseCwd(response: unknown): string | undefined {
const responseRecord = record(response);
return stringValue(responseRecord.cwd) ??
stringValue(record(responseRecord.thread).cwd);
}
function lastFinalTextFromTurn(turn: Record<string, unknown>): string {
const items = Array.isArray(turn.items) ? turn.items : [];
for (const item of [...items].reverse()) {
const candidate = record(item);
if (
candidate.type === "agentMessage" &&
candidate.phase === "final_answer"
) {
return stringValue(candidate.text)?.trim() ?? "";
}
}
return "";
}
function isTerminalTurnStatus(value: unknown): boolean {
return value === "completed" || value === "failed" || value === "interrupted";
}
function requiredArg(args: Record<string, unknown>, name: string): string {
const value = stringValue(args[name]);
if (!value) {
throw new Error(`Missing required argument: ${name}`);
}
return value;
}
function firstLine(value: string | undefined): string | undefined {
const line = value?.split(/\r?\n/, 1)[0]?.trim();
return line || undefined;
}
function compactId(value: string): string {
return value.length > 14 ? `${value.slice(0, 6)}...${value.slice(-6)}` : value;
}
function delegationId(threadId: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < threadId.length; index += 1) {
hash ^= threadId.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return `delegation-${(hash >>> 0).toString(16).padStart(8, "0")}`;
}
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;
}

View file

@ -0,0 +1,54 @@
export {
CodexWorkspaceBackendClient,
type CodexWorkspaceBackendClientOptions,
type CodexWorkspaceBackendTransport,
type WorkspaceBackendEvent,
} from "./client.ts";
export {
CodexWorkspaceBackendProtocolServer,
type CodexWorkspaceBackendAppServer,
type CodexWorkspaceBackendPeer,
type CodexWorkspaceBackendProtocolServerOptions,
type WorkspaceBackendMethodHandler,
} from "./server.ts";
export {
APP_SERVER_CALL_METHOD,
APP_SERVER_NOTIFICATION_METHOD,
APP_SERVER_NOTIFY_METHOD,
APP_SERVER_REQUEST_METHOD,
APP_SERVER_RESPOND_ERROR_METHOD,
APP_SERVER_RESPOND_METHOD,
WORKSPACE_BACKEND_EVENT_METHOD,
WORKSPACE_BACKEND_INITIALIZE_METHOD,
appServerCallParams,
appServerNotificationParams,
appServerNotifyParams,
appServerRequestParams,
appServerRespondErrorParams,
appServerRespondParams,
workspaceBackendEventParams,
workspaceBackendOwnedMethodPrefixes,
isWorkspaceBackendOwnedMethod,
type AppServerCallParams,
type AppServerNotificationParams,
type AppServerNotifyParams,
type AppServerRequestParams,
type AppServerRespondErrorParams,
type AppServerRespondParams,
type WorkspaceBackendEventParams,
type WorkspaceBackendInitializeParams,
type WorkspaceBackendInitializeResponse,
} from "./protocol.ts";
export {
WorkspaceDelegationCapability,
isTerminalDelegation,
returnModeFromArgs,
workspaceDelegationId,
type WorkspaceDelegation,
type WorkspaceDelegationAppServer,
type WorkspaceDelegationCapabilityOptions,
type WorkspaceDelegationReturnMode,
type WorkspaceDelegationState,
type WorkspaceDelegationStatus,
type WorkspacePendingWake,
} from "./delegation.ts";

View file

@ -4,8 +4,8 @@ import type {
JsonRpcRequest,
} from "../app-server/rpc.ts";
export const GATEWAY_INITIALIZE_METHOD = "gateway.initialize";
export const GATEWAY_EVENT_METHOD = "gateway.event";
export const WORKSPACE_BACKEND_INITIALIZE_METHOD = "workspace.initialize";
export const WORKSPACE_BACKEND_EVENT_METHOD = "workspace.event";
export const APP_SERVER_CALL_METHOD = "appServer.call";
export const APP_SERVER_NOTIFY_METHOD = "appServer.notify";
export const APP_SERVER_RESPOND_METHOD = "appServer.respond";
@ -13,7 +13,7 @@ export const APP_SERVER_RESPOND_ERROR_METHOD = "appServer.respondError";
export const APP_SERVER_NOTIFICATION_METHOD = "appServer.notification";
export const APP_SERVER_REQUEST_METHOD = "appServer.request";
export type GatewayInitializeParams = {
export type WorkspaceBackendInitializeParams = {
clientInfo?: {
name?: string;
title?: string | null;
@ -22,7 +22,7 @@ export type GatewayInitializeParams = {
capabilities?: Record<string, unknown>;
};
export type GatewayInitializeResponse = {
export type WorkspaceBackendInitializeResponse = {
ok: true;
serverInfo: {
name: string;
@ -30,7 +30,7 @@ export type GatewayInitializeResponse = {
};
capabilities: {
appServerPassThrough: true;
gatewayCommands: string[];
workspaceMethods: string[];
flowInspection: boolean;
};
};
@ -65,7 +65,7 @@ export type AppServerRequestParams = {
message: JsonRpcRequest;
};
export type GatewayEvent =
export type WorkspaceBackendEvent =
| {
type: "connected";
at: string;
@ -86,23 +86,23 @@ export type GatewayEvent =
message: string;
}
| {
type: "unsupportedGatewayCommand";
type: "unsupportedWorkspaceBackendMethod";
at: string;
method: string;
};
export type GatewayEventParams = {
event: GatewayEvent;
export type WorkspaceBackendEventParams = {
event: WorkspaceBackendEvent;
};
export const gatewayOwnedMethodPrefixes = [
"gateway.delegation.",
"gateway.workbench.",
"gateway.flow.",
export const workspaceBackendOwnedMethodPrefixes = [
"delegation.",
"workbench.",
"flow.",
] as const;
export function isGatewayOwnedMethod(method: string): boolean {
return gatewayOwnedMethodPrefixes.some((prefix) => method.startsWith(prefix));
export function isWorkspaceBackendOwnedMethod(method: string): boolean {
return workspaceBackendOwnedMethodPrefixes.some((prefix) => method.startsWith(prefix));
}
export function appServerCallParams(
@ -167,16 +167,16 @@ export function appServerRequestParams(
return message ? { message } : undefined;
}
export function gatewayEventParams(
export function workspaceBackendEventParams(
value: unknown,
): GatewayEventParams | undefined {
): WorkspaceBackendEventParams | undefined {
const input = record(value);
const event = record(input.event);
const type = stringValue(event.type);
if (!type) {
return undefined;
}
return { event: event as unknown as GatewayEvent };
return { event: event as unknown as WorkspaceBackendEvent };
}
function jsonRpcNotification(value: unknown): JsonRpcNotification | undefined {

View file

@ -15,18 +15,23 @@ import {
APP_SERVER_REQUEST_METHOD,
APP_SERVER_RESPOND_ERROR_METHOD,
APP_SERVER_RESPOND_METHOD,
GATEWAY_EVENT_METHOD,
GATEWAY_INITIALIZE_METHOD,
WORKSPACE_BACKEND_EVENT_METHOD,
WORKSPACE_BACKEND_INITIALIZE_METHOD,
appServerCallParams,
appServerNotifyParams,
appServerRespondErrorParams,
appServerRespondParams,
isGatewayOwnedMethod,
type GatewayEvent,
type GatewayInitializeResponse,
isWorkspaceBackendOwnedMethod,
type WorkspaceBackendEvent,
type WorkspaceBackendInitializeResponse,
} from "./protocol.ts";
export type CodexGatewayAppServer = CodexEventEmitter & {
export type WorkspaceBackendMethodHandler = (
params: unknown,
request: JsonRpcRequest,
) => unknown | Promise<unknown>;
export type CodexWorkspaceBackendAppServer = CodexEventEmitter & {
connect?(): Promise<void>;
close?(): void;
request<T = unknown>(method: string, params?: unknown): Promise<T>;
@ -35,35 +40,39 @@ export type CodexGatewayAppServer = CodexEventEmitter & {
respondError(id: JsonRpcId, code: number, message: string, data?: unknown): void;
};
export type CodexGatewayPeer = {
export type CodexWorkspaceBackendPeer = {
send(message: string): void;
};
export type CodexGatewayProtocolServerOptions = {
appServer: CodexGatewayAppServer;
export type CodexWorkspaceBackendProtocolServerOptions = {
appServer: CodexWorkspaceBackendAppServer;
now?: () => Date;
serverName?: string;
serverVersion?: string;
flowInspection?: boolean;
gatewayCommands?: string[];
workspaceMethods?: string[];
methods?: Record<string, WorkspaceBackendMethodHandler>;
};
export class CodexGatewayProtocolServer {
readonly appServer: CodexGatewayAppServer;
#peers = new Set<CodexGatewayPeer>();
export class CodexWorkspaceBackendProtocolServer {
readonly appServer: CodexWorkspaceBackendAppServer;
#peers = new Set<CodexWorkspaceBackendPeer>();
#now: () => Date;
#serverName: string;
#serverVersion: string;
#flowInspection: boolean;
#gatewayCommands: string[];
#workspaceMethods: string[];
#methods: Map<string, WorkspaceBackendMethodHandler>;
constructor(options: CodexGatewayProtocolServerOptions) {
constructor(options: CodexWorkspaceBackendProtocolServerOptions) {
this.appServer = options.appServer;
this.#now = options.now ?? (() => new Date());
this.#serverName = options.serverName ?? "codex-gateway-local";
this.#serverName = options.serverName ?? "codex-workspace-backend-local";
this.#serverVersion = options.serverVersion ?? "0.1.0";
this.#flowInspection = options.flowInspection ?? false;
this.#gatewayCommands = options.gatewayCommands ?? [];
this.#methods = new Map(Object.entries(options.methods ?? {}));
this.#workspaceMethods = options.workspaceMethods ??
[...this.#methods.keys()].sort();
this.appServer.on("notification", (message) => {
this.broadcastNotification(APP_SERVER_NOTIFICATION_METHOD, { message });
@ -72,14 +81,14 @@ export class CodexGatewayProtocolServer {
this.broadcastNotification(APP_SERVER_REQUEST_METHOD, { message });
});
this.appServer.on("error", (error) => {
this.broadcastGatewayEvent({
this.broadcastWorkspaceBackendEvent({
type: "appServer.error",
at: this.#now().toISOString(),
message: errorMessage(error),
});
});
this.appServer.on("close", (code, reason) => {
this.broadcastGatewayEvent({
this.broadcastWorkspaceBackendEvent({
type: "appServer.closed",
at: this.#now().toISOString(),
code: typeof code === "number" ? code : null,
@ -88,19 +97,19 @@ export class CodexGatewayProtocolServer {
});
}
addPeer(peer: CodexGatewayPeer): void {
addPeer(peer: CodexWorkspaceBackendPeer): void {
this.#peers.add(peer);
this.sendGatewayEvent(peer, {
this.sendWorkspaceBackendEvent(peer, {
type: "connected",
at: this.#now().toISOString(),
});
}
removePeer(peer: CodexGatewayPeer): void {
removePeer(peer: CodexWorkspaceBackendPeer): void {
this.#peers.delete(peer);
}
async handleMessage(peer: CodexGatewayPeer, data: string): Promise<void> {
async handleMessage(peer: CodexWorkspaceBackendPeer, data: string): Promise<void> {
let parsed: unknown;
try {
parsed = JSON.parse(data) as unknown;
@ -128,23 +137,28 @@ export class CodexGatewayProtocolServer {
}
}
broadcastGatewayEvent(event: GatewayEvent): void {
this.broadcastNotification(GATEWAY_EVENT_METHOD, { event });
broadcastWorkspaceBackendEvent(event: WorkspaceBackendEvent): void {
this.broadcastNotification(WORKSPACE_BACKEND_EVENT_METHOD, { event });
}
sendGatewayEvent(peer: CodexGatewayPeer, event: GatewayEvent): void {
sendWorkspaceBackendEvent(peer: CodexWorkspaceBackendPeer, event: WorkspaceBackendEvent): void {
peer.send(JSON.stringify({
jsonrpc: "2.0",
method: GATEWAY_EVENT_METHOD,
method: WORKSPACE_BACKEND_EVENT_METHOD,
params: { event },
} satisfies JsonRpcNotification));
}
async #handleRequest(request: JsonRpcRequest): Promise<JsonRpcResponse> {
try {
if (request.method === GATEWAY_INITIALIZE_METHOD) {
if (request.method === WORKSPACE_BACKEND_INITIALIZE_METHOD) {
return successResponse(request.id, this.#initializeResponse());
}
const workspaceMethod = this.#methods.get(request.method);
if (workspaceMethod) {
const result = await workspaceMethod(request.params, request);
return successResponse(request.id, result ?? { ok: true });
}
if (request.method === APP_SERVER_CALL_METHOD) {
const params = appServerCallParams(request.params);
if (!params) {
@ -186,19 +200,19 @@ export class CodexGatewayProtocolServer {
);
return successResponse(request.id, { ok: true });
}
if (isGatewayOwnedMethod(request.method)) {
this.broadcastGatewayEvent({
type: "unsupportedGatewayCommand",
if (isWorkspaceBackendOwnedMethod(request.method)) {
this.broadcastWorkspaceBackendEvent({
type: "unsupportedWorkspaceBackendMethod",
at: this.#now().toISOString(),
method: request.method,
});
return errorResponse(
request.id,
-32601,
`Gateway command is not implemented: ${request.method}`,
`Workspace backend method is not implemented: ${request.method}`,
);
}
return errorResponse(request.id, -32601, `Unknown gateway method: ${request.method}`);
return errorResponse(request.id, -32601, `Unknown workspace backend method: ${request.method}`);
} catch (error) {
return errorResponse(request.id, -32603, errorMessage(error));
}
@ -209,7 +223,7 @@ export class CodexGatewayProtocolServer {
if (notification.method === APP_SERVER_NOTIFY_METHOD) {
const params = appServerNotifyParams(notification.params);
if (!params) {
this.broadcastGatewayEvent({
this.broadcastWorkspaceBackendEvent({
type: "appServer.error",
at: this.#now().toISOString(),
message: "Invalid appServer.notify params",
@ -219,15 +233,15 @@ export class CodexGatewayProtocolServer {
this.appServer.notify(params.method, params.params);
return;
}
if (isGatewayOwnedMethod(notification.method)) {
this.broadcastGatewayEvent({
type: "unsupportedGatewayCommand",
if (isWorkspaceBackendOwnedMethod(notification.method)) {
this.broadcastWorkspaceBackendEvent({
type: "unsupportedWorkspaceBackendMethod",
at: this.#now().toISOString(),
method: notification.method,
});
}
} catch (error) {
this.broadcastGatewayEvent({
this.broadcastWorkspaceBackendEvent({
type: "appServer.error",
at: this.#now().toISOString(),
message: errorMessage(error),
@ -235,7 +249,7 @@ export class CodexGatewayProtocolServer {
}
}
#initializeResponse(): GatewayInitializeResponse {
#initializeResponse(): WorkspaceBackendInitializeResponse {
return {
ok: true,
serverInfo: {
@ -244,7 +258,7 @@ export class CodexGatewayProtocolServer {
},
capabilities: {
appServerPassThrough: true,
gatewayCommands: this.#gatewayCommands,
workspaceMethods: this.#workspaceMethods,
flowInspection: this.#flowInspection,
},
};

View file

@ -63,7 +63,7 @@ test("derives goal, plan, running command, activity, and final answer state", ()
params: {
threadId: "thread-1",
turnId: "turn-1",
item: dynamicToolItem("tool-1", "codex_gateway", "list_flow_runs", "completed"),
item: dynamicToolItem("tool-1", "codex_workspace", "list_flow_runs", "completed"),
completedAtMs: fixedNow.getTime(),
},
}, { now: fixedNow });
@ -102,7 +102,7 @@ test("derives goal, plan, running command, activity, and final answer state", ()
expect.objectContaining({
itemId: "tool-1",
kind: "tool",
label: "codex_gateway.list_flow_runs",
label: "codex_workspace.list_flow_runs",
status: "completed",
}),
]),

View file

@ -1,10 +1,10 @@
import { describe, expect, test } from "bun:test";
import {
CodexGatewayClient,
CodexGatewayProtocolServer,
type CodexGatewayAppServer,
type CodexGatewayPeer,
} from "../src/gateway/index.ts";
CodexWorkspaceBackendClient,
CodexWorkspaceBackendProtocolServer,
type CodexWorkspaceBackendAppServer,
type CodexWorkspaceBackendPeer,
} from "../src/workspace-backend/index.ts";
import { CodexEventEmitter } from "../src/app-server/events.ts";
import type {
JsonRpcId,
@ -12,10 +12,10 @@ import type {
JsonRpcResponse,
} from "../src/app-server/rpc.ts";
describe("Codex gateway protocol", () => {
describe("Codex workspace backend protocol", () => {
test("server proxies appServer.call without interpreting native app-server methods", async () => {
const appServer = new FakeAppServer();
const server = new CodexGatewayProtocolServer({ appServer });
const server = new CodexWorkspaceBackendProtocolServer({ appServer });
const peer = new MemoryPeer();
server.addPeer(peer);
@ -38,28 +38,50 @@ describe("Codex gateway protocol", () => {
});
});
test("server declares gateway-owned commands but does not fake implementations", async () => {
test("server handles registered workspace methods without forwarding them", async () => {
const appServer = new FakeAppServer();
const server = new CodexGatewayProtocolServer({ appServer });
const server = new CodexWorkspaceBackendProtocolServer({
appServer,
methods: {
"delegation.list": () => ({ delegations: [] }),
},
});
const peer = new MemoryPeer();
server.addPeer(peer);
await server.handleMessage(peer, JSON.stringify({
jsonrpc: "2.0",
id: "delegation",
method: "gateway.delegation.start",
method: "delegation.list",
params: {},
}));
expect(appServer.requests).toEqual([]);
expect(peer.response("delegation")?.result).toEqual({ delegations: [] });
});
test("unknown workspace backend methods return method not found", async () => {
const appServer = new FakeAppServer();
const server = new CodexWorkspaceBackendProtocolServer({ appServer });
const peer = new MemoryPeer();
server.addPeer(peer);
await server.handleMessage(peer, JSON.stringify({
jsonrpc: "2.0",
id: "delegation",
method: "delegation.start",
params: { prompt: "do work" },
}));
expect(appServer.requests).toEqual([]);
expect(peer.response("delegation")?.error?.code).toBe(-32601);
expect(peer.notifications("gateway.event")).toContainEqual(
expect(peer.notifications("workspace.event")).toContainEqual(
expect.objectContaining({
method: "gateway.event",
method: "workspace.event",
params: {
event: expect.objectContaining({
type: "unsupportedGatewayCommand",
method: "gateway.delegation.start",
type: "unsupportedWorkspaceBackendMethod",
method: "delegation.start",
}),
},
}),
@ -68,7 +90,7 @@ describe("Codex gateway protocol", () => {
test("server proxies appServer.notify notifications without a response", async () => {
const appServer = new FakeAppServer();
const server = new CodexGatewayProtocolServer({ appServer });
const server = new CodexWorkspaceBackendProtocolServer({ appServer });
const peer = new MemoryPeer();
await server.handleMessage(peer, JSON.stringify({
@ -87,8 +109,8 @@ describe("Codex gateway protocol", () => {
});
test("client uses appServer.call for native helpers and unwraps app-server notifications", async () => {
const transport = new FakeGatewayTransport();
const client = new CodexGatewayClient({
const transport = new FakeWorkspaceBackendTransport();
const client = new CodexWorkspaceBackendClient({
transport,
clientName: "test-web",
clientTitle: "Test Web",
@ -114,7 +136,7 @@ describe("Codex gateway protocol", () => {
expect(transport.requests).toEqual([
{
method: "gateway.initialize",
method: "workspace.initialize",
params: {
clientInfo: {
name: "test-web",
@ -151,7 +173,7 @@ describe("Codex gateway protocol", () => {
});
});
class FakeAppServer extends CodexEventEmitter implements CodexGatewayAppServer {
class FakeAppServer extends CodexEventEmitter implements CodexWorkspaceBackendAppServer {
requests: Array<{ method: string; params?: unknown }> = [];
notifications: Array<{ method: string; params?: unknown }> = [];
responses: Array<{ id: JsonRpcId; result: unknown }> = [];
@ -185,7 +207,7 @@ class FakeAppServer extends CodexEventEmitter implements CodexGatewayAppServer {
}
}
class MemoryPeer implements CodexGatewayPeer {
class MemoryPeer implements CodexWorkspaceBackendPeer {
messages: unknown[] = [];
send(message: string): void {
@ -205,7 +227,7 @@ class MemoryPeer implements CodexGatewayPeer {
}
}
class FakeGatewayTransport extends CodexEventEmitter {
class FakeWorkspaceBackendTransport extends CodexEventEmitter {
readonly requestTimeoutMs = 60_000;
requests: Array<{ method: string; params?: unknown }> = [];
started = false;
@ -220,13 +242,13 @@ class FakeGatewayTransport extends CodexEventEmitter {
async request<T = unknown>(method: string, params?: unknown): Promise<T> {
this.requests.push({ method, params });
if (method === "gateway.initialize") {
if (method === "workspace.initialize") {
return {
ok: true,
serverInfo: { name: "fake", version: "0.1.0" },
capabilities: {
appServerPassThrough: true,
gatewayCommands: [],
workspaceMethods: [],
flowInspection: false,
},
} as T;

View file

@ -68,7 +68,7 @@ const backend = createFlowBackendHttpClient({
const { runs } = await backend.listRuns({ status: "completed", limit: 20 });
```
The client normalizes systemd-local, Convex-adapter, and codex-service-style
The client normalizes workspace-local, Convex-adapter, and codex-service-style
run/event responses into stable view models with `processStatus`,
`resultStatus`, `effectiveStatus`, `needsAttention`, attempts, latest output,
and result payload data. Semantic statuses such as `blocked` and