Initial codex-flows monorepo

This commit is contained in:
matamune 2026-05-12 15:15:09 +00:00
commit 3c446b11a4
642 changed files with 19676 additions and 0 deletions

24
apps/cli/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "codex-app-cli",
"version": "0.1.0",
"description": "CLI for sending JSON-RPC commands to a running Codex app-server.",
"type": "module",
"private": true,
"license": "Apache-2.0",
"bin": {
"codex-app": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"check:types": "tsc --noEmit",
"test": "bun test test/*.test.ts"
},
"dependencies": {
"@peezy-tech/codex-flows": "workspace:*"
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

113
apps/cli/src/actions.ts Normal file
View file

@ -0,0 +1,113 @@
// TODO: generate this after generating app-server ts bindings on auto-update job when it exists
export const APP_SERVER_ACTIONS = [
"initialize",
"thread/start",
"thread/resume",
"thread/fork",
"thread/archive",
"thread/unsubscribe",
"thread/increment_elicitation",
"thread/decrement_elicitation",
"thread/name/set",
"thread/goal/set",
"thread/goal/get",
"thread/goal/clear",
"thread/metadata/update",
"thread/memoryMode/set",
"memory/reset",
"thread/unarchive",
"thread/compact/start",
"thread/shellCommand",
"thread/approveGuardianDeniedAction",
"thread/backgroundTerminals/clean",
"thread/rollback",
"thread/list",
"thread/loaded/list",
"thread/read",
"thread/turns/list",
"thread/turns/items/list",
"thread/inject_items",
"skills/list",
"hooks/list",
"marketplace/add",
"marketplace/remove",
"marketplace/upgrade",
"plugin/list",
"plugin/read",
"plugin/skill/read",
"plugin/share/save",
"plugin/share/updateTargets",
"plugin/share/list",
"plugin/share/delete",
"app/list",
"fs/readFile",
"fs/writeFile",
"fs/createDirectory",
"fs/getMetadata",
"fs/readDirectory",
"fs/remove",
"fs/copy",
"fs/watch",
"fs/unwatch",
"skills/config/write",
"plugin/install",
"plugin/uninstall",
"turn/start",
"turn/steer",
"turn/interrupt",
"thread/realtime/start",
"thread/realtime/appendAudio",
"thread/realtime/appendText",
"thread/realtime/stop",
"thread/realtime/listVoices",
"review/start",
"model/list",
"modelProvider/capabilities/read",
"experimentalFeature/list",
"experimentalFeature/enablement/set",
"collaborationMode/list",
"mock/experimentalMethod",
"mcpServer/oauth/login",
"config/mcpServer/reload",
"mcpServerStatus/list",
"mcpServer/resource/read",
"mcpServer/tool/call",
"windowsSandbox/setupStart",
"windowsSandbox/readiness",
"account/login/start",
"account/login/cancel",
"account/logout",
"account/rateLimits/read",
"account/sendAddCreditsNudgeEmail",
"feedback/upload",
"command/exec",
"command/exec/write",
"command/exec/terminate",
"command/exec/resize",
"process/spawn",
"process/writeStdin",
"process/kill",
"process/resizePty",
"config/read",
"externalAgentConfig/detect",
"externalAgentConfig/import",
"config/value/write",
"config/batchWrite",
"configRequirements/read",
"account/read",
"getConversationSummary",
"gitDiffToRemote",
"getAuthStatus",
"fuzzyFileSearch",
"fuzzyFileSearch/sessionStart",
"fuzzyFileSearch/sessionUpdate",
"fuzzyFileSearch/sessionStop",
] as const;
export type AppServerAction = (typeof APP_SERVER_ACTIONS)[number];
const actionSet = new Set<string>(APP_SERVER_ACTIONS);
export function isAppServerAction(value: string): value is AppServerAction {
return actionSet.has(value);
}

115
apps/cli/src/args.ts Normal file
View file

@ -0,0 +1,115 @@
import { isAppServerAction, type AppServerAction } from "./actions.ts";
export type ParsedArgs =
| { type: "help" }
| { type: "actions" }
| {
type: "call";
action: AppServerAction;
paramsText: string | undefined;
url: string;
timeoutMs: number;
pretty: boolean;
};
export const DEFAULT_WS_URL = "ws://127.0.0.1:3585";
const defaultTimeoutMs = 90_000;
export function parseArgs(argv: string[], env: NodeJS.ProcessEnv): ParsedArgs {
const positionals: string[] = [];
let url = env.CODEX_WORKSPACE_APP_SERVER_WS_URL ?? DEFAULT_WS_URL;
let timeoutMs = defaultTimeoutMs;
let pretty = true;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg) {
continue;
}
if (arg === "-h" || arg === "--help") {
return { type: "help" };
}
if (arg === "--url" || arg === "--ws-url") {
const value = argv[index + 1];
if (!value) {
throw new Error(`${arg} requires a WebSocket URL`);
}
url = value;
index += 1;
continue;
}
if (arg.startsWith("--url=")) {
url = arg.slice("--url=".length);
continue;
}
if (arg.startsWith("--ws-url=")) {
url = arg.slice("--ws-url=".length);
continue;
}
if (arg === "--timeout-ms") {
const value = argv[index + 1];
if (!value) {
throw new Error("--timeout-ms requires a number");
}
timeoutMs = parseTimeout(value);
index += 1;
continue;
}
if (arg.startsWith("--timeout-ms=")) {
timeoutMs = parseTimeout(arg.slice("--timeout-ms=".length));
continue;
}
if (arg === "--compact") {
pretty = false;
continue;
}
if (arg === "--pretty") {
pretty = true;
continue;
}
if (arg === "--") {
positionals.push(...argv.slice(index + 1));
break;
}
if (arg.startsWith("-")) {
throw new Error(`Unknown option: ${arg}`);
}
positionals.push(arg);
}
const command = positionals[0];
if (!command) {
return { type: "help" };
}
if (command === "help") {
return { type: "help" };
}
if (command === "actions") {
return { type: "actions" };
}
const action = command === "call" ? positionals[1] : command;
const paramsParts = command === "call" ? positionals.slice(2) : positionals.slice(1);
if (!action) {
throw new Error("call requires an action name");
}
if (!isAppServerAction(action)) {
throw new Error(`Unknown action: ${action}`);
}
return {
type: "call",
action,
paramsText: paramsParts.length > 0 ? paramsParts.join(" ") : undefined,
url,
timeoutMs,
pretty,
};
}
function parseTimeout(value: string) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error("--timeout-ms must be a positive integer");
}
return parsed;
}

123
apps/cli/src/index.ts Normal file
View file

@ -0,0 +1,123 @@
#!/usr/bin/env bun
import { CodexAppServerClient } from "@peezy-tech/codex-flows";
import { APP_SERVER_ACTIONS } from "./actions.ts";
import { DEFAULT_WS_URL, parseArgs } from "./args.ts";
async function main() {
try {
const parsed = parseArgs(Bun.argv.slice(2), process.env);
switch (parsed.type) {
case "help":
write(helpText());
return;
case "actions":
write(`${APP_SERVER_ACTIONS.join("\n")}\n`);
return;
case "call":
await callAction(parsed);
return;
}
} catch (error) {
writeError(`${errorMessage(error)}\n`);
process.exitCode = 1;
}
}
type CallArgs = Extract<ReturnType<typeof parseArgs>, { type: "call" }>;
async function callAction(args: CallArgs) {
const params = await readParams(args.paramsText);
const client = new CodexAppServerClient({
webSocketTransportOptions: {
url: args.url,
requestTimeoutMs: args.timeoutMs,
},
clientName: "codex-app-cli",
clientTitle: "Codex App CLI",
clientVersion: "0.1.0",
});
client.on("request", (message) => {
client.respondError(message.id, -32603, "codex-app CLI does not handle server requests");
});
try {
await client.connect();
const result = await client.request(args.action, params);
write(formatJson(result, args.pretty));
} finally {
client.close();
}
}
async function readParams(paramsText: string | undefined) {
if (paramsText !== undefined) {
return parseJsonParams(paramsText);
}
if (process.stdin.isTTY) {
return undefined;
}
const text = await readStdin();
if (!text.trim()) {
return undefined;
}
return parseJsonParams(text);
}
async function readStdin() {
let text = "";
for await (const chunk of process.stdin) {
text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
}
return text;
}
function parseJsonParams(text: string) {
try {
return JSON.parse(text) as unknown;
} catch (error) {
throw new Error(`Failed to parse params JSON: ${errorMessage(error)}`);
}
}
function formatJson(value: unknown, pretty: boolean) {
return `${JSON.stringify(value, null, pretty ? 2 : 0)}\n`;
}
function helpText() {
return `codex-app sends JSON-RPC actions to a running Codex app-server.
Usage:
codex-app [options] <action> [params-json]
codex-app [options] call <action> [params-json]
echo '<params-json>' | codex-app [options] <action>
codex-app actions
Options:
--url, --ws-url <url> App-server WebSocket URL
Defaults to CODEX_WORKSPACE_APP_SERVER_WS_URL or ${DEFAULT_WS_URL}
--timeout-ms <ms> Request timeout in milliseconds
--compact Print compact JSON
--pretty Print pretty JSON
-h, --help Show this help
Examples:
codex-app thread/list '{"limit": 20, "sourceKinds": []}'
echo '{"refreshToken": false}' | codex-app account/read
`;
}
function write(text: string) {
process.stdout.write(text);
}
function writeError(text: string) {
process.stderr.write(text);
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
await main();

View file

@ -0,0 +1,55 @@
import { expect, test } from "bun:test";
import { parseArgs } from "../src/args.ts";
test("parses a direct action call with params JSON", () => {
expect(parseArgs(["thread/list", "{\"limit\":10}"], {})).toEqual({
type: "call",
action: "thread/list",
paramsText: "{\"limit\":10}",
url: "ws://127.0.0.1:3585",
timeoutMs: 90_000,
pretty: true,
});
});
test("parses call alias, url, timeout, and compact output", () => {
expect(
parseArgs(
[
"--url",
"ws://localhost:4000",
"--timeout-ms=1234",
"--compact",
"call",
"account/read",
],
{},
),
).toEqual({
type: "call",
action: "account/read",
paramsText: undefined,
url: "ws://localhost:4000",
timeoutMs: 1234,
pretty: false,
});
});
test("uses environment URL default", () => {
const parsed = parseArgs(["account/read"], {
CODEX_WORKSPACE_APP_SERVER_WS_URL: "ws://127.0.0.1:9999",
});
expect(parsed).toMatchObject({
type: "call",
url: "ws://127.0.0.1:9999",
});
});
test("rejects unknown actions before connecting", () => {
expect(() => parseArgs(["not-a-method"], {})).toThrow("Unknown action");
});
test("completion command is not supported", () => {
expect(() => parseArgs(["completion", "zsh"], {})).toThrow("Unknown action");
});

24
apps/cli/tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@peezy-tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy-tech/codex-flows/*": ["../../packages/codex-client/src/*"]
}
},
"include": ["src", "test"]
}

View file

@ -0,0 +1,27 @@
{
"name": "codex-discord-bridge",
"version": "0.1.0",
"description": "Long-lived Discord sidecar for bridging Discord threads to Codex app-server threads.",
"type": "module",
"private": true,
"license": "Apache-2.0",
"bin": {
"codex-discord-bridge": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"check:types": "tsc --noEmit",
"pretty-log": "bun ./src/pretty-log.ts",
"start:debug:commentary": "bun ./src/index.ts --local-app-server --debug --progress-mode commentary",
"test": "bun test test/*.test.ts"
},
"dependencies": {
"@peezy-tech/codex-flows": "workspace:*",
"discord.js": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,368 @@
import os from "node:os";
import path from "node:path";
import type {
ReasoningEffort,
ReasoningSummary,
v2,
} from "@peezy-tech/codex-flows/generated";
import type {
DiscordBridgeConfig,
DiscordConsoleOutputMode,
DiscordProgressMode,
} from "./types.ts";
import type { DiscordBridgeLogLevelSetting } from "./logger.ts";
export type ParsedConfig =
| {
type: "run";
discordToken: string;
appServerUrl?: string;
localAppServer?: boolean;
config: DiscordBridgeConfig;
}
| { type: "help"; text: string };
const effortValues = new Set<ReasoningEffort>([
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
]);
const summaryValues = new Set<ReasoningSummary>([
"auto",
"concise",
"detailed",
"none",
]);
const progressModeValues = new Set<DiscordProgressMode>([
"summary",
"commentary",
"none",
]);
const consoleOutputValues = new Set<DiscordConsoleOutputMode>([
"messages",
"none",
]);
const logLevelValues = new Set<DiscordBridgeLogLevelSetting>([
"debug",
"info",
"warn",
"error",
"silent",
]);
const approvalPolicyValues = new Set<string>([
"untrusted",
"on-failure",
"on-request",
"never",
]);
const sandboxValues = new Set<v2.SandboxMode>([
"read-only",
"workspace-write",
"danger-full-access",
]);
export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfig {
const args = parseFlags(argv);
if (args.has("help") || args.has("h")) {
return { type: "help", text: helpText() };
}
const discordToken = stringFlag(args, "token") ?? env.CODEX_DISCORD_BOT_TOKEN;
if (!discordToken) {
throw new Error("Missing Discord bot token. Set CODEX_DISCORD_BOT_TOKEN or pass --token.");
}
const allowedUserIds = csvSet(
stringFlag(args, "allowed-user-ids") ?? env.CODEX_DISCORD_ALLOWED_USER_IDS,
);
if (allowedUserIds.size === 0) {
throw new Error(
"Missing allowed Discord users. Set CODEX_DISCORD_ALLOWED_USER_IDS or pass --allowed-user-ids.",
);
}
const explicitAppServerUrl =
stringFlag(args, "app-server-url") ??
stringFlag(args, "url");
const localAppServer = booleanFlag(args, "local-app-server");
if (localAppServer && explicitAppServerUrl) {
throw new Error("Cannot set both --local-app-server and --app-server-url.");
}
const appServerUrl = localAppServer
? undefined
: explicitAppServerUrl ?? env.CODEX_WORKSPACE_APP_SERVER_WS_URL;
const statePath =
stringFlag(args, "state-path") ??
env.CODEX_DISCORD_STATE_PATH ??
path.join(os.homedir(), ".codex", "discord-bridge", "state.json");
const permissionsProfile = stringFlag(args, "permissions-profile") ??
env.CODEX_DISCORD_PERMISSIONS_PROFILE;
const approvalPolicy = optionalApprovalPolicy(
stringFlag(args, "approval-policy") ?? env.CODEX_DISCORD_APPROVAL_POLICY,
);
const sandbox = optionalSandbox(
stringFlag(args, "sandbox") ?? env.CODEX_DISCORD_SANDBOX,
);
if (sandbox && permissionsProfile) {
throw new Error("Cannot set both --sandbox and --permissions-profile.");
}
const debug = booleanFlag(args, "debug") || envFlag(env.CODEX_DISCORD_DEBUG);
const logLevel = optionalLogLevel(
stringFlag(args, "log-level") ?? env.CODEX_DISCORD_LOG_LEVEL,
) ?? (debug ? "debug" : undefined);
return {
type: "run",
discordToken,
appServerUrl,
localAppServer,
config: {
allowedUserIds,
allowedChannelIds: csvSet(
stringFlag(args, "allowed-channel-ids") ??
env.CODEX_DISCORD_ALLOWED_CHANNEL_IDS,
),
statePath,
cwd: resolveHomeDir(
stringFlag(args, "dir") ??
stringFlag(args, "positional-dir") ??
env.CODEX_DISCORD_DIR ??
stringFlag(args, "cwd") ??
env.CODEX_DISCORD_CWD,
),
model: stringFlag(args, "model") ?? env.CODEX_DISCORD_MODEL,
modelProvider:
stringFlag(args, "model-provider") ??
env.CODEX_DISCORD_MODEL_PROVIDER,
serviceTier:
stringFlag(args, "service-tier") ?? env.CODEX_DISCORD_SERVICE_TIER,
effort: optionalEffort(
stringFlag(args, "effort") ?? env.CODEX_DISCORD_EFFORT,
),
summary: optionalSummary(
stringFlag(args, "summary") ??
env.CODEX_DISCORD_REASONING_SUMMARY ??
"auto",
),
progressMode: optionalProgressMode(
stringFlag(args, "progress-mode") ??
env.CODEX_DISCORD_PROGRESS_MODE ??
"summary",
),
consoleOutput: optionalConsoleOutput(
stringFlag(args, "console-output") ??
env.CODEX_DISCORD_CONSOLE_OUTPUT,
),
logLevel,
approvalPolicy,
sandbox,
permissions: permissionsProfile
? { type: "profile", id: permissionsProfile }
: undefined,
debug,
},
};
}
function parseFlags(argv: string[]): Map<string, string | boolean> {
const flags = new Map<string, string | boolean>();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg?.startsWith("--")) {
if (flags.has("positional-dir")) {
throw new Error(`Unexpected argument: ${arg ?? ""}`);
}
flags.set("positional-dir", arg ?? "");
continue;
}
const [rawName, inlineValue] = arg.slice(2).split("=", 2);
if (!rawName) {
throw new Error(`Invalid flag: ${arg}`);
}
if (inlineValue !== undefined) {
flags.set(rawName, inlineValue);
continue;
}
if (booleanFlagNames.has(rawName)) {
flags.set(rawName, true);
continue;
}
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
flags.set(rawName, true);
continue;
}
flags.set(rawName, next);
index += 1;
}
if (
flags.has("positional-dir") &&
(flags.has("dir") || flags.has("cwd"))
) {
throw new Error("Cannot set both positional directory and --dir/--cwd.");
}
return flags;
}
const booleanFlagNames = new Set(["debug", "help", "h", "local-app-server"]);
function stringFlag(
flags: Map<string, string | boolean>,
name: string,
): string | undefined {
const value = flags.get(name);
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function csvSet(value: string | undefined): Set<string> {
return new Set(
(value ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
);
}
function booleanFlag(flags: Map<string, string | boolean>, name: string): boolean {
const value = flags.get(name);
if (value === true) {
return true;
}
return envFlag(typeof value === "string" ? value : undefined);
}
function envFlag(value: string | undefined): boolean {
return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
}
function optionalEffort(value: string | undefined): ReasoningEffort | undefined {
if (!value) {
return undefined;
}
if (!effortValues.has(value as ReasoningEffort)) {
throw new Error("Invalid effort. Expected none, minimal, low, medium, high, or xhigh.");
}
return value as ReasoningEffort;
}
function optionalSummary(value: string | undefined): ReasoningSummary | undefined {
if (!value) {
return undefined;
}
if (!summaryValues.has(value as ReasoningSummary)) {
throw new Error("Invalid summary. Expected auto, concise, detailed, or none.");
}
return value as ReasoningSummary;
}
function optionalProgressMode(value: string | undefined): DiscordProgressMode | undefined {
if (!value) {
return undefined;
}
if (!progressModeValues.has(value as DiscordProgressMode)) {
throw new Error("Invalid progress mode. Expected summary, commentary, or none.");
}
return value as DiscordProgressMode;
}
function optionalConsoleOutput(
value: string | undefined,
): DiscordConsoleOutputMode | undefined {
if (!value) {
return undefined;
}
if (!consoleOutputValues.has(value as DiscordConsoleOutputMode)) {
throw new Error("Invalid console output. Expected messages or none.");
}
return value as DiscordConsoleOutputMode;
}
function optionalLogLevel(
value: string | undefined,
): DiscordBridgeLogLevelSetting | undefined {
if (!value) {
return undefined;
}
if (!logLevelValues.has(value as DiscordBridgeLogLevelSetting)) {
throw new Error("Invalid log level. Expected debug, info, warn, error, or silent.");
}
return value as DiscordBridgeLogLevelSetting;
}
function optionalApprovalPolicy(
value: string | undefined,
): v2.AskForApproval | undefined {
if (!value) {
return undefined;
}
if (!approvalPolicyValues.has(value)) {
throw new Error(
"Invalid approval policy. Expected untrusted, on-failure, on-request, or never.",
);
}
return value as v2.AskForApproval;
}
function optionalSandbox(value: string | undefined): v2.SandboxMode | undefined {
if (!value) {
return undefined;
}
if (!sandboxValues.has(value as v2.SandboxMode)) {
throw new Error(
"Invalid sandbox. Expected read-only, workspace-write, or danger-full-access.",
);
}
return value as v2.SandboxMode;
}
function helpText(): string {
return `codex-discord-bridge connects Discord threads to Codex app-server threads.
Usage:
codex-discord-bridge [options] [dir]
Required:
--token <token> Discord bot token, or CODEX_DISCORD_BOT_TOKEN
--allowed-user-ids <ids> Comma-separated Discord user ids, or CODEX_DISCORD_ALLOWED_USER_IDS
Options:
--app-server-url <url> Existing app-server WebSocket URL
--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
[dir] Optional Codex thread directory, resolved from home
--dir <path> Codex thread directory, resolved from home
--cwd <path> Alias for --dir
--model <model> Codex model override
--model-provider <provider> Codex model provider override
--service-tier <tier> Codex service tier override
--effort <effort> none|minimal|low|medium|high|xhigh
--summary <summary> auto|concise|detailed|none
--progress-mode <mode> summary|commentary|none
--console-output <mode> messages|none
--log-level <level> debug|info|warn|error|silent
--approval-policy <policy> untrusted|on-failure|on-request|never
--sandbox <mode> read-only|workspace-write|danger-full-access
--permissions-profile <id> Named Codex permissions profile
--debug Emit verbose bridge diagnostics to stderr
--help Show this help
`;
}
function resolveHomeDir(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
if (value === "~") {
return os.homedir();
}
if (value.startsWith("~/")) {
return path.join(os.homedir(), value.slice(2));
}
if (path.isAbsolute(value)) {
return value;
}
return path.join(os.homedir(), value);
}

View file

@ -0,0 +1,99 @@
export type DiscordConsoleMessageKind =
| "summary"
| "commentary"
| "final"
| "error";
export type DiscordConsoleMessage = {
kind: DiscordConsoleMessageKind;
text: string;
discordThreadId: string;
codexThreadId: string;
turnId?: string;
title?: string;
at?: Date;
};
export type DiscordConsoleOutput = {
message(message: DiscordConsoleMessage): void;
};
export type ConsoleMessageOutputOptions = {
color?: boolean;
now?: () => Date;
stream?: Pick<NodeJS.WriteStream, "write">;
};
export type ConsoleMessageFormatOptions = {
color?: boolean;
now?: () => Date;
};
const resetColor = "\x1b[0m";
const kindColors: Record<DiscordConsoleMessageKind, string> = {
summary: "\x1b[90m",
commentary: "\x1b[36m",
final: "\x1b[32m",
error: "\x1b[31m",
};
export function createDiscordConsoleOutput(
options: ConsoleMessageOutputOptions = {},
): DiscordConsoleOutput {
const stream = options.stream ?? process.stdout;
const color = options.color ??
Boolean(process.stdout.isTTY && !process.env.NO_COLOR);
const now = options.now ?? (() => new Date());
return {
message(message) {
stream.write(`${formatConsoleMessage(message, { color, now })}\n`);
},
};
}
export function formatConsoleMessage(
message: DiscordConsoleMessage,
options: ConsoleMessageFormatOptions = {},
): string {
const now = options.now ?? (() => new Date());
const time = formatTime(message.at ?? now());
const kind = message.kind.toUpperCase().padEnd(10);
const coloredKind = colorize(kind, kindColors[message.kind], options.color ?? false);
const title = (message.title?.trim() || compactId(message.codexThreadId)).replace(
/\s+/g,
" ",
);
const metadata = [
`thread=${compactId(message.codexThreadId)}`,
message.turnId ? `turn=${compactId(message.turnId)}` : undefined,
].filter(Boolean).join(" ");
const header = `[${time}] ${coloredKind} ${title} ${metadata}`;
const body = formatBody(message.text);
return body ? `${header}\n${body}` : header;
}
function formatBody(text: string): string {
const trimmed = text.trim();
if (!trimmed) {
return "";
}
return trimmed
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
}
function formatTime(date: Date): string {
return date.toISOString().slice(11, 23);
}
function compactId(id: string): string {
if (id.length <= 12) {
return id;
}
return `${id.slice(0, 6)}...${id.slice(-4)}`;
}
function colorize(text: string, color: string, enabled: boolean): string {
return enabled ? `${color}${text}${resetColor}` : text;
}

View file

@ -0,0 +1,380 @@
import {
Client,
Events,
GatewayIntentBits,
type Interaction,
type Message,
} from "discord.js";
import { splitDiscordMessage } from "./bridge.ts";
import {
createDiscordBridgeLogger,
type DiscordBridgeLogger,
} from "./logger.ts";
import type {
DiscordBridgeTransport,
DiscordBridgeTransportHandlers,
} from "./types.ts";
export type DiscordJsBridgeTransportOptions = {
token: string;
logger?: DiscordBridgeLogger;
};
export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
#token: string;
#logger: DiscordBridgeLogger;
#client: Client | undefined;
#handlers: DiscordBridgeTransportHandlers | undefined;
constructor(options: DiscordJsBridgeTransportOptions) {
this.#token = options.token;
this.#logger = options.logger ?? createDiscordBridgeLogger();
}
async start(handlers: DiscordBridgeTransportHandlers): Promise<void> {
this.#handlers = handlers;
if (this.#client) {
return;
}
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.MessageContent,
],
});
this.#client = client;
client.once(Events.ClientReady, (readyClient) => {
this.#logger.info("discord.connected", {
userId: readyClient.user.id,
tag: readyClient.user.tag,
});
});
client.on(Events.MessageCreate, (message) => this.#handleMessage(message));
client.on(Events.InteractionCreate, (interaction) =>
void this.#handleInteraction(interaction).catch((error) => {
this.#logger.error("discord.interaction.failed", {
error: errorMessage(error),
});
})
);
await client.login(this.#token);
}
async stop(): Promise<void> {
this.#client?.destroy();
this.#client = undefined;
}
async registerCommands(): Promise<void> {
const application = this.#client?.application;
if (!application) {
return;
}
await application.commands.set([
{
name: "clear",
description: "Delete inactive Codex bridge threads",
},
]);
}
async createThread(
channelId: string,
name: string,
sourceMessageId?: string,
): Promise<string> {
const channel = await this.#sendableChannel(channelId);
if (sourceMessageId) {
const messages = getMessagesManager(channel);
if (messages) {
const sourceMessage = await messages.fetch(sourceMessageId);
if (sourceMessage.startThread) {
const thread = await sourceMessage.startThread({
name,
autoArchiveDuration: 1440,
reason: "Codex Discord bridge thread",
});
if (thread.id) {
return thread.id;
}
}
}
}
const threads = getThreadsManager(channel);
if (!threads) {
throw new Error(`Discord channel cannot create threads: ${channelId}`);
}
const thread = await threads.create({
name,
autoArchiveDuration: 1440,
reason: "Codex Discord bridge thread",
});
if (!thread.id) {
throw new Error("Discord did not return a thread id");
}
return thread.id;
}
async sendMessage(channelId: string, text: string): Promise<string[]> {
const channel = await this.#sendableChannel(channelId);
const messageIds: string[] = [];
for (const chunk of splitDiscordMessage(text)) {
const sent = await channel.send({
content: chunk,
allowedMentions: {
parse: [],
users: [],
roles: [],
repliedUser: false,
},
});
if (typeof sent.id === "string") {
messageIds.push(sent.id);
}
}
return messageIds;
}
async updateMessage(
channelId: string,
messageId: string,
text: string,
): Promise<void> {
const channel = await this.#sendableChannel(channelId);
const messages = getMessagesManager(channel);
if (!messages) {
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
}
const message = await messages.fetch(messageId);
await message.edit({
content: splitDiscordMessage(text)[0] ?? "",
allowedMentions: {
parse: [],
users: [],
roles: [],
repliedUser: false,
},
});
}
async deleteMessage(channelId: string, messageId: string): Promise<void> {
const channel = await this.#sendableChannel(channelId);
const messages = getMessagesManager(channel);
if (!messages) {
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
}
const message = await messages.fetch(messageId);
await message.delete();
}
async deleteThread(channelId: string): Promise<void> {
const client = this.#client;
if (!client) {
throw new Error("Discord bridge is not connected");
}
const channel = await client.channels.fetch(channelId);
if (!channel || !("delete" in channel) || typeof channel.delete !== "function") {
throw new Error(`Discord channel cannot be deleted: ${channelId}`);
}
await channel.delete("Codex Discord bridge clear command");
}
async addThreadMembers(channelId: string, userIds: string[]): Promise<void> {
const channel = await this.#sendableChannel(channelId);
const members = getThreadMembersManager(channel);
if (!members) {
throw new Error(`Discord channel cannot add thread members: ${channelId}`);
}
for (const userId of userIds) {
await members.add(userId);
}
}
async pinMessage(channelId: string, messageId: string): Promise<void> {
const channel = await this.#sendableChannel(channelId);
const messages = getMessagesManager(channel);
if (!messages) {
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
}
const message = await messages.fetch(messageId);
if (!message.pin) {
throw new Error(`Discord message cannot be pinned: ${messageId}`);
}
if (message.pinned) {
return;
}
await message.pin();
}
async sendTyping(channelId: string): Promise<void> {
const channel = await this.#sendableChannel(channelId);
await channel.sendTyping?.();
}
#handleMessage(message: Message): void {
const botUserId = this.#client?.user?.id;
if (
botUserId &&
!isThreadChannel(message.channel) &&
message.mentions.users.has(botUserId)
) {
const mentionedUserIds = message.mentions.users
.filter((user) => user.id !== botUserId && !user.bot)
.map((user) => user.id);
const prompt = stripUserMentions(message.content ?? "", [
botUserId,
...mentionedUserIds,
]);
this.#handlers?.onInbound({
kind: "threadStart",
sourceMessageId: message.id,
channelId: message.channelId,
guildId: message.guildId ?? undefined,
author: {
id: message.author.id,
name: message.member?.displayName ||
message.author.globalName ||
message.author.username,
isBot: message.author.bot,
},
prompt,
mentionedUserIds,
createdAt: message.createdAt.toISOString(),
});
return;
}
this.#handlers?.onInbound({
kind: "message",
channelId: message.channelId,
guildId: message.guildId ?? undefined,
messageId: message.id,
author: {
id: message.author.id,
name: message.member?.displayName ||
message.author.globalName ||
message.author.username,
isBot: message.author.bot,
},
content: message.content ?? "",
createdAt: message.createdAt.toISOString(),
});
}
async #handleInteraction(interaction: Interaction): Promise<void> {
if (!interaction.isChatInputCommand() || interaction.commandName !== "clear") {
return;
}
const channelId = interaction.channelId;
this.#handlers?.onInbound({
kind: "clear",
channelId,
guildId: interaction.guildId ?? undefined,
author: {
id: interaction.user.id,
name: interaction.member && "displayName" in interaction.member
? String(interaction.member.displayName)
: interaction.user.globalName || interaction.user.username,
isBot: interaction.user.bot,
},
createdAt: new Date().toISOString(),
reply: async (text) => {
await interaction.reply({
content: text,
ephemeral: true,
allowedMentions: {
parse: [],
users: [],
roles: [],
repliedUser: false,
},
});
},
});
}
async #sendableChannel(channelId: string): Promise<SendableChannel> {
const client = this.#client;
if (!client) {
throw new Error("Discord bridge is not connected");
}
const channel = await client.channels.fetch(channelId);
if (!channel || !("send" in channel)) {
throw new Error(`Discord channel is not text-sendable: ${channelId}`);
}
return channel as unknown as SendableChannel;
}
}
type ThreadCreateOptions = {
name: string;
autoArchiveDuration?: number;
reason?: string;
};
type SendableChannel = {
id: string;
send(options: Record<string, unknown>): Promise<{ id?: string }>;
sendTyping?: () => Promise<void>;
threads?: {
create(options: ThreadCreateOptions): Promise<{ id?: string }>;
};
members?: {
add(userId: string): Promise<unknown>;
};
messages?: {
fetch(messageId: string): Promise<{
delete(): Promise<unknown>;
edit(options: Record<string, unknown>): Promise<unknown>;
pinned?: boolean;
pin?(): Promise<unknown>;
startThread?(options: ThreadCreateOptions): Promise<{ id?: string }>;
}>;
};
};
function getThreadsManager(
channel: SendableChannel,
): SendableChannel["threads"] | undefined {
return channel.threads;
}
function getMessagesManager(
channel: SendableChannel,
): SendableChannel["messages"] | undefined {
return channel.messages;
}
function getThreadMembersManager(
channel: SendableChannel,
): SendableChannel["members"] | undefined {
return channel.members;
}
function isThreadChannel(channel: unknown): boolean {
return Boolean(
channel &&
typeof channel === "object" &&
"isThread" in channel &&
typeof channel.isThread === "function" &&
channel.isThread(),
);
}
function stripUserMentions(content: string, userIds: string[]): string {
let stripped = content;
for (const userId of userIds) {
stripped = stripped.replace(new RegExp(`<@!?${escapeRegExp(userId)}>`, "g"), "");
}
return stripped.trim();
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -0,0 +1,96 @@
#!/usr/bin/env bun
import {
CodexAppServerClient,
CodexStdioTransport,
} from "@peezy-tech/codex-flows";
import { DiscordCodexBridge } from "./bridge.ts";
import { createDiscordConsoleOutput } from "./console-output.ts";
import { parseConfig } from "./config.ts";
import { DiscordJsBridgeTransport } from "./discord-transport.ts";
import { createDiscordBridgeLogger } from "./logger.ts";
import { JsonFileStateStore } from "./state.ts";
async function main(): Promise<void> {
let logger = createDiscordBridgeLogger();
try {
const parsed = parseConfig(Bun.argv.slice(2), process.env);
if (parsed.type === "help") {
process.stdout.write(parsed.text);
return;
}
logger = createDiscordBridgeLogger({
debug: parsed.config.debug,
logLevel: parsed.config.logLevel,
});
const consoleOutput = parsed.config.consoleOutput === "messages"
? createDiscordConsoleOutput()
: undefined;
const client = new CodexAppServerClient({
transport: parsed.localAppServer
? new CodexStdioTransport({
args: localAppServerArgs(),
requestTimeoutMs: 90_000,
})
: undefined,
webSocketTransportOptions: parsed.appServerUrl
? { url: parsed.appServerUrl, requestTimeoutMs: 90_000 }
: undefined,
clientName: "codex-discord-bridge",
clientTitle: "Codex Discord Bridge",
clientVersion: "0.1.0",
});
const bridge = new DiscordCodexBridge({
client,
transport: new DiscordJsBridgeTransport({
token: parsed.discordToken,
logger,
}),
store: new JsonFileStateStore(parsed.config.statePath),
config: parsed.config,
logger,
consoleOutput,
});
await bridge.start();
logger.info("bridge.started", {
appServerUrl: parsed.appServerUrl ?? "local",
localAppServer: Boolean(parsed.localAppServer),
progressMode: parsed.config.progressMode ?? "summary",
statePath: parsed.config.statePath,
});
await waitForShutdown(bridge);
} catch (error) {
logger.error("bridge.fatal", { error: errorMessage(error) });
process.exitCode = 1;
}
}
function localAppServerArgs(): string[] {
return [
"app-server",
"--listen",
"stdio://",
"--enable",
"apps",
"--enable",
"hooks",
];
}
function waitForShutdown(bridge: DiscordCodexBridge): Promise<void> {
return new Promise((resolve) => {
const shutdown = () => {
process.off("SIGINT", shutdown);
process.off("SIGTERM", shutdown);
void bridge.stop().finally(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

@ -0,0 +1,71 @@
export type DiscordBridgeLogLevel = "debug" | "info" | "warn" | "error";
export type DiscordBridgeLogLevelSetting = DiscordBridgeLogLevel | "silent";
export type DiscordBridgeLogFields = Record<string, unknown>;
export type DiscordBridgeLogger = {
debug(event: string, fields?: DiscordBridgeLogFields): void;
info(event: string, fields?: DiscordBridgeLogFields): void;
warn(event: string, fields?: DiscordBridgeLogFields): void;
error(event: string, fields?: DiscordBridgeLogFields): void;
};
export type DiscordBridgeLoggerOptions = {
component?: string;
debug?: boolean;
logLevel?: DiscordBridgeLogLevelSetting;
now?: () => Date;
stream?: Pick<NodeJS.WriteStream, "write">;
};
const logLevelRanks: Record<DiscordBridgeLogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
export function createDiscordBridgeLogger(
options: DiscordBridgeLoggerOptions = {},
): DiscordBridgeLogger {
const component = options.component ?? "codex-discord-bridge";
const now = options.now ?? (() => new Date());
const stream = options.stream ?? process.stderr;
const logLevel = options.logLevel ?? (options.debug ? "debug" : "info");
const write = (
level: DiscordBridgeLogLevel,
event: string,
fields: DiscordBridgeLogFields = {},
): void => {
if (!shouldWrite(level, logLevel)) {
return;
}
stream.write(
`${JSON.stringify({
time: now().toISOString(),
component,
level,
event,
...fields,
})}\n`,
);
};
return {
debug: (event, fields) => write("debug", event, fields),
info: (event, fields) => write("info", event, fields),
warn: (event, fields) => write("warn", event, fields),
error: (event, fields) => write("error", event, fields),
};
}
function shouldWrite(
level: DiscordBridgeLogLevel,
configured: DiscordBridgeLogLevelSetting,
): boolean {
if (configured === "silent") {
return false;
}
return logLevelRanks[level] >= logLevelRanks[configured];
}

View file

@ -0,0 +1,225 @@
#!/usr/bin/env bun
import type { DiscordBridgeLogLevel } from "./logger.ts";
type PrettyLogOptions = {
color?: boolean;
name?: string;
now?: () => Date;
};
type PrettyLogRecord = Record<string, unknown> & {
component?: unknown;
event?: unknown;
level?: unknown;
message?: unknown;
time?: unknown;
};
const reservedFields = new Set(["time", "component", "level", "event"]);
const resetColor = "\x1b[0m";
const levelColors: Record<DiscordBridgeLogLevel, string> = {
debug: "\x1b[90m",
info: "\x1b[36m",
warn: "\x1b[33m",
error: "\x1b[31m",
};
export function formatPrettyLogLine(
line: string,
options: PrettyLogOptions = {},
): string {
const now = options.now ?? (() => new Date());
const record = parseRecord(line);
if (!record) {
return formatParts({
color: options.color ?? false,
component: options.name ?? "process",
fields: "",
level: "info",
message: line,
time: formatTime(now()),
});
}
const level = normalizeLevel(record.level);
const message = stringifyMainMessage(record);
return formatParts({
color: options.color ?? false,
component: stringifyComponent(record.component, options.name),
fields: stringifyFields(record),
level,
message,
time: formatTime(record.time, now),
});
}
export async function runPrettyLogCli(
args: string[],
input: AsyncIterable<string | Uint8Array>,
output: Pick<NodeJS.WriteStream, "write">,
): Promise<void> {
const options = parseCliArgs(args);
let buffer = "";
for await (const chunk of input) {
buffer += typeof chunk === "string"
? chunk
: Buffer.from(chunk).toString("utf8");
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = trimTrailingCarriageReturn(buffer.slice(0, newlineIndex));
output.write(`${formatPrettyLogLine(line, options)}\n`);
buffer = buffer.slice(newlineIndex + 1);
newlineIndex = buffer.indexOf("\n");
}
}
if (buffer.length > 0) {
output.write(
`${formatPrettyLogLine(trimTrailingCarriageReturn(buffer), options)}\n`,
);
}
}
function parseCliArgs(args: string[]): PrettyLogOptions {
const options: PrettyLogOptions = {
color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR),
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--name") {
const name = args[index + 1];
if (!name) {
throw new Error("Missing value for --name");
}
options.name = name;
index += 1;
continue;
}
if (arg === "--color") {
options.color = true;
continue;
}
if (arg === "--no-color") {
options.color = false;
continue;
}
throw new Error(`Unexpected argument: ${arg ?? ""}`);
}
return options;
}
function parseRecord(line: string): PrettyLogRecord | undefined {
try {
const value: unknown = JSON.parse(line);
return value !== null && typeof value === "object"
? value as PrettyLogRecord
: undefined;
} catch {
return undefined;
}
}
function normalizeLevel(level: unknown): DiscordBridgeLogLevel {
if (typeof level !== "string") {
return "info";
}
const normalized = level.toLowerCase();
if (
normalized === "debug" || normalized === "info" || normalized === "warn" ||
normalized === "error"
) {
return normalized;
}
return "info";
}
function stringifyComponent(component: unknown, fallback: string | undefined): string {
return typeof component === "string" && component.length > 0
? component
: fallback ?? "process";
}
function stringifyMainMessage(record: PrettyLogRecord): string {
if (typeof record.event === "string" && record.event.length > 0) {
return record.event;
}
if (typeof record.message === "string" && record.message.length > 0) {
return record.message;
}
return "log";
}
function stringifyFields(record: PrettyLogRecord): string {
const fields: string[] = [];
for (const [key, value] of Object.entries(record)) {
if (reservedFields.has(key) || value === undefined) {
continue;
}
if (key === "message" && typeof record.event !== "string") {
continue;
}
fields.push(`${key}=${stringifyFieldValue(value)}`);
}
return fields.join(" ");
}
function stringifyFieldValue(value: unknown): string {
if (typeof value === "string") {
return /^[^\s=]+$/.test(value) ? value : JSON.stringify(value);
}
if (
typeof value === "number" || typeof value === "boolean" || value === null
) {
return String(value);
}
return JSON.stringify(value) ?? String(value);
}
function formatTime(time: unknown, now?: () => Date): string {
const date = time instanceof Date
? time
: typeof time === "string" || typeof time === "number"
? new Date(time)
: now?.() ?? new Date();
if (Number.isNaN(date.getTime())) {
const fallback = now?.() ?? new Date();
return fallback.toISOString().slice(11, 23);
}
return date.toISOString().slice(11, 23);
}
function formatParts(options: {
color: boolean;
component: string;
fields: string;
level: DiscordBridgeLogLevel;
message: string;
time: string;
}): string {
const level = options.level.toUpperCase().padEnd(5);
const coloredLevel = colorize(level, levelColors[options.level], options.color);
const message = options.fields.length > 0
? `${options.message} ${options.fields}`
: options.message;
return `[${options.time}] ${coloredLevel} ${options.component} ${message}`;
}
function colorize(text: string, color: string, enabled: boolean): string {
return enabled ? `${color}${text}${resetColor}` : text;
}
function trimTrailingCarriageReturn(line: string): string {
return line.endsWith("\r") ? line.slice(0, -1) : line;
}
if (import.meta.main) {
try {
await runPrettyLogCli(Bun.argv.slice(2), process.stdin, process.stdout);
} catch (error) {
process.stderr.write(
`pretty-log failed: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
process.exitCode = 1;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,222 @@
import { mkdir, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import type {
DiscordBridgeActiveTurn,
DiscordBridgeDelivery,
DiscordBridgeQueueItem,
DiscordBridgeSession,
DiscordBridgeState,
DiscordBridgeStateStore,
} from "./types.ts";
const maxProcessedMessageIds = 1000;
const maxDeliveries = 500;
export class JsonFileStateStore implements DiscordBridgeStateStore {
readonly path: string;
constructor(filePath: string) {
this.path = path.resolve(filePath);
}
async load(): Promise<DiscordBridgeState> {
const file = Bun.file(this.path);
if (!(await file.exists())) {
return emptyState();
}
const parsed = JSON.parse(await file.text()) as unknown;
return parseState(parsed);
}
async save(state: DiscordBridgeState): Promise<void> {
trimState(state);
await mkdir(path.dirname(this.path), { recursive: true });
const tempPath = `${this.path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
await writeFile(tempPath, `${JSON.stringify(state, null, 2)}\n`);
await rename(tempPath, this.path);
}
}
export class MemoryStateStore implements DiscordBridgeStateStore {
state: DiscordBridgeState;
constructor(state: DiscordBridgeState = emptyState()) {
this.state = structuredClone(state);
}
async load(): Promise<DiscordBridgeState> {
return structuredClone(this.state);
}
async save(state: DiscordBridgeState): Promise<void> {
this.state = structuredClone(state);
}
}
export function emptyState(): DiscordBridgeState {
return {
version: 1,
sessions: [],
queue: [],
activeTurns: [],
processedMessageIds: [],
deliveries: [],
};
}
export function trimState(state: DiscordBridgeState): void {
state.processedMessageIds = state.processedMessageIds.slice(
-maxProcessedMessageIds,
);
state.deliveries = state.deliveries.slice(-maxDeliveries);
}
function parseState(value: unknown): DiscordBridgeState {
if (!isRecord(value) || value.version !== 1) {
throw new Error("Invalid Discord bridge state file");
}
return {
version: 1,
sessions: Array.isArray(value.sessions)
? value.sessions.map(parseSession)
: [],
queue: Array.isArray(value.queue) ? value.queue.map(parseQueueItem) : [],
activeTurns: Array.isArray(value.activeTurns)
? value.activeTurns.map(parseActiveTurn)
: [],
processedMessageIds: Array.isArray(value.processedMessageIds)
? value.processedMessageIds.filter(
(candidate): candidate is string => typeof candidate === "string",
)
: [],
deliveries: Array.isArray(value.deliveries)
? value.deliveries.map(parseDelivery)
: [],
};
}
function parseActiveTurn(value: unknown): DiscordBridgeActiveTurn {
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge active turn");
}
const origin = value.origin === "discord" || value.origin === "external"
? value.origin
: "external";
return {
turnId: requiredString(value.turnId, "activeTurns.turnId"),
discordThreadId: requiredString(value.discordThreadId, "activeTurns.discordThreadId"),
codexThreadId: requiredString(value.codexThreadId, "activeTurns.codexThreadId"),
origin,
queueItemId: optionalString(value.queueItemId),
startedAt: optionalString(value.startedAt),
observedAt: requiredString(value.observedAt, "activeTurns.observedAt"),
};
}
function parseSession(value: unknown): DiscordBridgeSession {
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge session");
}
return {
discordThreadId: requiredString(value.discordThreadId, "session.discordThreadId"),
parentChannelId: requiredString(value.parentChannelId, "session.parentChannelId"),
guildId: optionalString(value.guildId),
sourceMessageId: optionalString(value.sourceMessageId),
codexThreadId: requiredString(value.codexThreadId, "session.codexThreadId"),
title: requiredString(value.title, "session.title"),
createdAt: requiredString(value.createdAt, "session.createdAt"),
ownerUserId: optionalString(value.ownerUserId),
participantUserIds: Array.isArray(value.participantUserIds)
? uniqueStrings(value.participantUserIds)
: undefined,
cwd: optionalString(value.cwd),
mode: parseSessionMode(value.mode),
statusMessageId: optionalString(value.statusMessageId),
};
}
function parseQueueItem(value: unknown): DiscordBridgeQueueItem {
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge queue item");
}
const status = value.status;
if (status !== "pending" && status !== "processing" && status !== "failed") {
throw new Error("Invalid Discord bridge queue item status");
}
return {
id: requiredString(value.id, "queue.id"),
status,
discordMessageId: requiredString(value.discordMessageId, "queue.discordMessageId"),
discordThreadId: requiredString(value.discordThreadId, "queue.discordThreadId"),
codexThreadId: requiredString(value.codexThreadId, "queue.codexThreadId"),
authorId: requiredString(value.authorId, "queue.authorId"),
authorName: requiredString(value.authorName, "queue.authorName"),
content: requiredString(value.content, "queue.content"),
createdAt: requiredString(value.createdAt, "queue.createdAt"),
receivedAt: requiredString(value.receivedAt, "queue.receivedAt"),
attempts: optionalNumber(value.attempts) ?? 0,
turnId: optionalString(value.turnId),
lastError: optionalString(value.lastError),
nextAttemptAt: optionalString(value.nextAttemptAt),
};
}
function parseDelivery(value: unknown): DiscordBridgeDelivery {
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge delivery");
}
const kind = value.kind;
if (
kind !== "summary" &&
kind !== "commentary" &&
kind !== "final" &&
kind !== "error"
) {
throw new Error("Invalid Discord bridge delivery kind");
}
return {
discordMessageId: requiredString(value.discordMessageId, "delivery.discordMessageId"),
discordThreadId: requiredString(value.discordThreadId, "delivery.discordThreadId"),
codexThreadId: requiredString(value.codexThreadId, "delivery.codexThreadId"),
turnId: optionalString(value.turnId),
kind,
outboundMessageIds: Array.isArray(value.outboundMessageIds)
? value.outboundMessageIds.filter(
(candidate): candidate is string => typeof candidate === "string",
)
: [],
deliveredAt: requiredString(value.deliveredAt, "delivery.deliveredAt"),
};
}
function requiredString(value: unknown, fieldName: string): string {
const parsed = optionalString(value);
if (!parsed) {
throw new Error(`Invalid Discord bridge state ${fieldName}: expected string`);
}
return parsed;
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function optionalNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function parseSessionMode(value: unknown): DiscordBridgeSession["mode"] {
return value === "new" || value === "resumed" ? value : undefined;
}
function uniqueStrings(values: unknown[]): string[] {
return [...new Set(values.filter(
(candidate): candidate is string => typeof candidate === "string" && candidate.length > 0,
))];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -0,0 +1,177 @@
import type {
ReasoningEffort,
ReasoningSummary,
v2,
} from "@peezy-tech/codex-flows/generated";
import type { JsonRpcNotification, JsonRpcRequest } from "@peezy-tech/codex-flows/rpc";
import type { DiscordBridgeLogLevelSetting } from "./logger.ts";
export type DiscordBridgeConfig = {
allowedUserIds: Set<string>;
allowedChannelIds: Set<string>;
statePath: string;
cwd?: string;
model?: string;
modelProvider?: string;
serviceTier?: string;
effort?: ReasoningEffort;
summary?: ReasoningSummary;
approvalPolicy?: v2.AskForApproval;
sandbox?: v2.SandboxMode;
permissions?: v2.PermissionProfileSelectionParams;
typingIntervalMs?: number;
reconcileIntervalMs?: number;
progressMode?: DiscordProgressMode;
consoleOutput?: DiscordConsoleOutputMode;
logLevel?: DiscordBridgeLogLevelSetting;
debug?: boolean;
};
export type DiscordProgressMode = "summary" | "commentary" | "none";
export type DiscordConsoleOutputMode = "messages" | "none";
export type DiscordAuthor = {
id: string;
name: string;
isBot: boolean;
};
export type DiscordMessageInbound = {
kind: "message";
channelId: string;
guildId?: string;
messageId: string;
author: DiscordAuthor;
content: string;
createdAt: string;
};
export type DiscordThreadStartInbound = {
kind: "threadStart";
sourceMessageId: string;
channelId: string;
guildId?: string;
author: DiscordAuthor;
prompt?: string;
mentionedUserIds?: string[];
title?: string;
createdAt: string;
reply?: (text: string) => Promise<void>;
};
export type DiscordClearInbound = {
kind: "clear";
channelId: string;
guildId?: string;
author: DiscordAuthor;
createdAt: string;
reply?: (text: string) => Promise<void>;
};
export type DiscordInbound =
| DiscordMessageInbound
| DiscordThreadStartInbound
| DiscordClearInbound;
export type DiscordBridgeTransportHandlers = {
onInbound(inbound: DiscordInbound): void;
};
export type DiscordBridgeTransport = {
start(handlers: DiscordBridgeTransportHandlers): Promise<void>;
stop(): Promise<void>;
registerCommands(): Promise<void>;
createThread(
channelId: string,
name: string,
sourceMessageId?: string,
): Promise<string>;
sendMessage(channelId: string, text: string): Promise<string[]>;
updateMessage?(channelId: string, messageId: string, text: string): Promise<void>;
deleteMessage(channelId: string, messageId: string): Promise<void>;
deleteThread?(channelId: string): Promise<void>;
addThreadMembers?(channelId: string, userIds: string[]): Promise<void>;
pinMessage?(channelId: string, messageId: string): Promise<void>;
sendTyping(channelId: string): Promise<void>;
};
export type CodexBridgeClient = {
connect(): Promise<void>;
close(): void;
on(event: "notification", listener: (message: JsonRpcNotification) => void): unknown;
on(event: "request", listener: (message: JsonRpcRequest) => void): unknown;
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>;
steerTurn(params: v2.TurnSteerParams): Promise<v2.TurnSteerResponse>;
readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse>;
getThreadGoal(params: v2.ThreadGoalGetParams): Promise<v2.ThreadGoalGetResponse>;
respondError(id: string | number, code: number, message: string, data?: unknown): void;
};
export type DiscordBridgeState = {
version: 1;
sessions: DiscordBridgeSession[];
queue: DiscordBridgeQueueItem[];
activeTurns: DiscordBridgeActiveTurn[];
processedMessageIds: string[];
deliveries: DiscordBridgeDelivery[];
};
export type DiscordBridgeSession = {
discordThreadId: string;
parentChannelId: string;
guildId?: string;
sourceMessageId?: string;
codexThreadId: string;
title: string;
createdAt: string;
ownerUserId?: string;
participantUserIds?: string[];
cwd?: string;
mode?: "new" | "resumed";
statusMessageId?: string;
};
export type DiscordBridgeQueueItem = {
id: string;
status: "pending" | "processing" | "failed";
discordMessageId: string;
discordThreadId: string;
codexThreadId: string;
authorId: string;
authorName: string;
content: string;
createdAt: string;
receivedAt: string;
attempts: number;
turnId?: string;
lastError?: string;
nextAttemptAt?: string;
};
export type DiscordBridgeActiveTurn = {
turnId: string;
discordThreadId: string;
codexThreadId: string;
origin: "discord" | "external";
queueItemId?: string;
startedAt?: string;
observedAt: string;
};
export type DiscordBridgeDelivery = {
discordMessageId: string;
discordThreadId: string;
codexThreadId: string;
turnId?: string;
kind: "summary" | "commentary" | "final" | "error";
outboundMessageIds: string[];
deliveredAt: string;
};
export type DiscordBridgeStateStore = {
load(): Promise<DiscordBridgeState>;
save(state: DiscordBridgeState): Promise<void>;
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,223 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "bun:test";
import { parseConfig } from "../src/config.ts";
describe("parseConfig", () => {
test("resolves --dir relative to the home directory", () => {
const parsed = parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--dir",
"projects/demo",
],
{},
);
expect(parsed.type).toBe("run");
if (parsed.type === "run") {
expect(parsed.config.cwd).toBe(path.join(os.homedir(), "projects/demo"));
}
});
test("expands tilde dir paths from the home directory", () => {
const parsed = parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--dir",
"~/projects/demo",
],
{},
);
expect(parsed.type).toBe("run");
if (parsed.type === "run") {
expect(parsed.config.cwd).toBe(path.join(os.homedir(), "projects/demo"));
}
});
test("accepts one positional directory for root script usage", () => {
const parsed = parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--local-app-server",
"~/game-protocol-workspace",
],
{ CODEX_DISCORD_DIR: "env-dir" },
);
expect(parsed.type).toBe("run");
if (parsed.type === "run") {
expect(parsed.localAppServer).toBe(true);
expect(parsed.config.cwd).toBe(
path.join(os.homedir(), "game-protocol-workspace"),
);
}
});
test("rejects multiple directory arguments", () => {
expect(() =>
parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"one",
"two",
],
{},
)
).toThrow("Unexpected argument: two");
expect(() =>
parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--dir",
"one",
"two",
],
{},
)
).toThrow("Cannot set both positional directory and --dir/--cwd.");
});
test("prefers CODEX_DISCORD_DIR over legacy cwd env", () => {
const parsed = parseConfig(
["--token", "discord-token", "--allowed-user-ids", "user-1"],
{
CODEX_DISCORD_DIR: "current",
CODEX_DISCORD_CWD: "/legacy",
},
);
expect(parsed.type).toBe("run");
if (parsed.type === "run") {
expect(parsed.config.cwd).toBe(path.join(os.homedir(), "current"));
}
});
test("enables debug logging from flag or environment", () => {
const fromFlag = parseConfig(
["--token", "discord-token", "--allowed-user-ids", "user-1", "--debug"],
{},
);
const fromEnv = parseConfig(
["--token", "discord-token", "--allowed-user-ids", "user-1"],
{ CODEX_DISCORD_DEBUG: "true" },
);
expect(fromFlag.type).toBe("run");
expect(fromEnv.type).toBe("run");
if (fromFlag.type === "run" && fromEnv.type === "run") {
expect(fromFlag.config.debug).toBe(true);
expect(fromEnv.config.debug).toBe(true);
}
});
test("parses progress mode from flag or environment", () => {
const fromFlag = parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--progress-mode",
"commentary",
],
{},
);
const fromEnv = parseConfig(
["--token", "discord-token", "--allowed-user-ids", "user-1"],
{ CODEX_DISCORD_PROGRESS_MODE: "none" },
);
expect(fromFlag.type).toBe("run");
expect(fromEnv.type).toBe("run");
if (fromFlag.type === "run" && fromEnv.type === "run") {
expect(fromFlag.config.progressMode).toBe("commentary");
expect(fromEnv.config.progressMode).toBe("none");
}
});
test("parses console output and log level from flag or environment", () => {
const fromFlag = parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--console-output",
"messages",
"--log-level",
"warn",
],
{},
);
const fromEnv = parseConfig(
["--token", "discord-token", "--allowed-user-ids", "user-1"],
{
CODEX_DISCORD_CONSOLE_OUTPUT: "none",
CODEX_DISCORD_LOG_LEVEL: "silent",
},
);
expect(fromFlag.type).toBe("run");
expect(fromEnv.type).toBe("run");
if (fromFlag.type === "run" && fromEnv.type === "run") {
expect(fromFlag.config.consoleOutput).toBe("messages");
expect(fromFlag.config.logLevel).toBe("warn");
expect(fromEnv.config.consoleOutput).toBe("none");
expect(fromEnv.config.logLevel).toBe("silent");
}
});
test("can force a local app-server even when workspace URL env is set", () => {
const parsed = parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--local-app-server",
],
{ CODEX_WORKSPACE_APP_SERVER_WS_URL: "ws://127.0.0.1:9999" },
);
expect(parsed.type).toBe("run");
if (parsed.type === "run") {
expect(parsed.localAppServer).toBe(true);
expect(parsed.appServerUrl).toBeUndefined();
}
});
test("rejects mixing local and explicit external app-server modes", () => {
expect(() =>
parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--local-app-server",
"--app-server-url",
"ws://127.0.0.1:9999",
],
{},
)
).toThrow("Cannot set both --local-app-server and --app-server-url.");
});
});

View file

@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import {
createDiscordConsoleOutput,
formatConsoleMessage,
} from "../src/console-output.ts";
describe("discord bridge console output", () => {
test("formats delivered assistant messages for terminal output", () => {
expect(
formatConsoleMessage(
{
kind: "final",
text: "Repo scan complete.\nNo regressions found.",
discordThreadId: "discord-thread-123456",
codexThreadId: "codex-thread-abcdef",
turnId: "turn-1234567890",
title: "Scan repo",
at: new Date("2026-05-12T04:22:00.123Z"),
},
{ color: false },
),
).toBe(
[
"[04:22:00.123] FINAL Scan repo thread=codex-...cdef turn=turn-1...7890",
" Repo scan complete.",
" No regressions found.",
].join("\n"),
);
});
test("writes one formatted block per message", () => {
const output = createMemoryOutput();
const consoleOutput = createDiscordConsoleOutput({
color: false,
now: () => new Date("2026-05-12T04:22:01.456Z"),
stream: output.stream,
});
consoleOutput.message({
kind: "commentary",
text: "I will inspect the bridge.",
discordThreadId: "discord-thread-1",
codexThreadId: "codex-thread-1",
turnId: "turn-1",
title: "Bridge status",
});
expect(output.text).toBe(
[
"[04:22:01.456] COMMENTARY Bridge status thread=codex-...ad-1 turn=turn-1",
" I will inspect the bridge.",
"",
].join("\n"),
);
});
});
function createMemoryOutput(): {
readonly stream: Pick<NodeJS.WriteStream, "write">;
readonly text: string;
} {
const chunks: string[] = [];
return {
stream: {
write: ((chunk: string | Uint8Array) => {
chunks.push(
typeof chunk === "string"
? chunk
: Buffer.from(chunk).toString("utf8"),
);
return true;
}) as NodeJS.WriteStream["write"],
},
get text() {
return chunks.join("");
},
};
}

View file

@ -0,0 +1,98 @@
import { describe, expect, test } from "bun:test";
import { createDiscordBridgeLogger } from "../src/logger.ts";
import { formatPrettyLogLine } from "../src/pretty-log.ts";
describe("discord bridge logger", () => {
test("writes info logs as structured json and gates debug logs", () => {
const output = createMemoryOutput();
const logger = createDiscordBridgeLogger({
component: "test-bridge",
now: () => new Date("2026-05-12T04:22:00.123Z"),
stream: output.stream,
});
logger.debug("hidden.debug", { threadId: "thread-1" });
logger.info("bridge.started", {
appServerUrl: "local",
statePath: "/tmp/discord-state.json",
});
const lines = output.text.trim().split("\n");
expect(lines).toHaveLength(1);
expect(JSON.parse(lines[0] ?? "")).toEqual({
time: "2026-05-12T04:22:00.123Z",
component: "test-bridge",
level: "info",
event: "bridge.started",
appServerUrl: "local",
statePath: "/tmp/discord-state.json",
});
});
test("filters logs below the configured log level", () => {
const output = createMemoryOutput();
const logger = createDiscordBridgeLogger({
component: "test-bridge",
logLevel: "warn",
now: () => new Date("2026-05-12T04:22:00.123Z"),
stream: output.stream,
});
logger.debug("hidden.debug");
logger.info("hidden.info");
logger.warn("visible.warn");
logger.error("visible.error");
expect(output.text.trim().split("\n").map((line) => JSON.parse(line).event))
.toEqual(["visible.warn", "visible.error"]);
});
test("pretty prints structured json logs and plain process output", () => {
const structured = formatPrettyLogLine(
JSON.stringify({
time: "2026-05-12T04:22:00.123Z",
component: "codex-discord-bridge",
level: "info",
event: "bridge.started",
appServerUrl: "local",
localAppServer: true,
}),
{ color: false },
);
const plain = formatPrettyLogLine("listening on ws://127.0.0.1:3585", {
color: false,
name: "codex-remote-control",
now: () => new Date("2026-05-12T04:22:01.456Z"),
});
expect(structured).toBe(
"[04:22:00.123] INFO codex-discord-bridge bridge.started appServerUrl=local localAppServer=true",
);
expect(plain).toBe(
"[04:22:01.456] INFO codex-remote-control listening on ws://127.0.0.1:3585",
);
});
});
function createMemoryOutput(): {
readonly stream: Pick<NodeJS.WriteStream, "write">;
readonly text: string;
} {
const chunks: string[] = [];
return {
stream: {
write: ((chunk: string | Uint8Array) => {
chunks.push(
typeof chunk === "string"
? chunk
: Buffer.from(chunk).toString("utf8"),
);
return true;
}) as NodeJS.WriteStream["write"],
},
get text() {
return chunks.join("");
},
};
}

View file

@ -0,0 +1,103 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "bun:test";
import { JsonFileStateStore } from "../src/state.ts";
describe("JsonFileStateStore", () => {
test("loads per-thread grant metadata and older sessions without grants", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "discord-bridge-state-"));
try {
const statePath = path.join(dir, "state.json");
await writeFile(
statePath,
`${JSON.stringify({
version: 1,
sessions: [
{
discordThreadId: "discord-thread-1",
parentChannelId: "parent-channel",
sourceMessageId: "message-start-1",
codexThreadId: "codex-thread-1",
title: "Granted thread",
createdAt: "2026-05-11T00:00:00.000Z",
ownerUserId: "user-1",
participantUserIds: ["user-2", "", "user-2", "user-3"],
cwd: "/workspace/project",
mode: "resumed",
statusMessageId: "message-status-1",
},
{
discordThreadId: "discord-thread-2",
parentChannelId: "parent-channel",
codexThreadId: "codex-thread-2",
title: "Older thread",
createdAt: "2026-05-11T00:00:00.000Z",
},
],
queue: [],
activeTurns: [
{
turnId: "turn-active-1",
discordThreadId: "discord-thread-1",
codexThreadId: "codex-thread-1",
origin: "external",
startedAt: "2026-05-11T00:00:01.000Z",
observedAt: "2026-05-11T00:00:02.000Z",
},
{
turnId: "turn-active-2",
discordThreadId: "discord-thread-2",
codexThreadId: "codex-thread-2",
origin: "unknown",
queueItemId: "queue-1",
observedAt: "2026-05-11T00:00:03.000Z",
},
],
processedMessageIds: [],
deliveries: [],
})}\n`,
);
const state = await new JsonFileStateStore(statePath).load();
expect(state.sessions).toHaveLength(2);
expect(state.sessions[0]?.ownerUserId).toBe("user-1");
expect(state.sessions[0]?.sourceMessageId).toBe("message-start-1");
expect(state.sessions[0]?.participantUserIds).toEqual([
"user-2",
"user-3",
]);
expect(state.sessions[0]?.cwd).toBe("/workspace/project");
expect(state.sessions[0]?.mode).toBe("resumed");
expect(state.sessions[0]?.statusMessageId).toBe("message-status-1");
expect(state.sessions[1]?.ownerUserId).toBeUndefined();
expect(state.sessions[1]?.sourceMessageId).toBeUndefined();
expect(state.sessions[1]?.participantUserIds).toBeUndefined();
expect(state.sessions[1]?.cwd).toBeUndefined();
expect(state.sessions[1]?.mode).toBeUndefined();
expect(state.sessions[1]?.statusMessageId).toBeUndefined();
expect(state.activeTurns).toEqual([
{
turnId: "turn-active-1",
discordThreadId: "discord-thread-1",
codexThreadId: "codex-thread-1",
origin: "external",
startedAt: "2026-05-11T00:00:01.000Z",
observedAt: "2026-05-11T00:00:02.000Z",
},
{
turnId: "turn-active-2",
discordThreadId: "discord-thread-2",
codexThreadId: "codex-thread-2",
origin: "external",
queueItemId: "queue-1",
observedAt: "2026-05-11T00:00:03.000Z",
},
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"types": ["node", "bun"],
"baseUrl": ".",
"paths": {
"@peezy-tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy-tech/codex-flows/*": ["../../packages/codex-client/src/*"]
}
},
"include": ["src", "test"]
}

23
apps/web/components.json Normal file
View file

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-lyra",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "@workspace/ui/lib/utils",
"ui": "@workspace/ui/components"
},
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle"
}

14
apps/web/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Codex Workspace Service</title>
<link rel="icon" href="data:," />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
apps/web/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "web",
"version": "0.0.1",
"type": "module",
"private": true,
"license": "Apache-2.0",
"scripts": {
"build": "tsc -b && vite build",
"check:types": "tsc --noEmit",
"dev": "vite --host 127.0.0.1",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@workspace/ui": "workspace:*",
"@peezy-tech/codex-flows": "workspace:*",
"lucide-react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"typescript": "catalog:",
"vite": "catalog:"
}
}

886
apps/web/src/App.tsx Normal file
View file

@ -0,0 +1,886 @@
import { Button } from "@workspace/ui/components/button";
import {
AlertCircle,
Copy,
Loader2,
Plug,
RefreshCw,
Send,
Square,
TerminalSquare,
Unplug,
} from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
type ReactNode,
} from "react";
import {
CodexAppServerClient,
JsonRpcError,
type JsonRpcNotification,
type JsonRpcRequest,
type v2,
} from "@peezy-tech/codex-flows/browser";
import { ThemeProvider } from "./components/theme-provider.tsx";
type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
type EventLogEntry = {
id: string;
at: string;
kind: "notification" | "request" | "error" | "control";
title: string;
body?: string;
};
const defaultWsUrl =
import.meta.env.VITE_CODEX_APP_SERVER_WS_URL ?? defaultProxiedWsUrl();
export function App() {
return (
<ThemeProvider>
<BareCodexApp />
</ThemeProvider>
);
}
function BareCodexApp() {
const clientRef = useRef<CodexAppServerClient | null>(null);
const [wsUrl, setWsUrl] = useState(initialWsUrl);
const [connectedUrl, setConnectedUrl] = useState<string>();
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
const [error, setError] = useState<string>();
const [threads, setThreads] = useState<v2.Thread[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string>();
const [selectedThread, setSelectedThread] = useState<v2.Thread>();
const [account, setAccount] = useState<v2.GetAccountResponse>();
const [prompt, setPrompt] = useState("");
const [cwd, setCwd] = useState("");
const [eventLog, setEventLog] = useState<EventLogEntry[]>([]);
const [busyAction, setBusyAction] = useState<string>();
const appendEvent = useCallback((entry: Omit<EventLogEntry, "id" | "at">) => {
setEventLog((current) =>
[
{
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
at: new Date().toISOString(),
...entry,
},
...current,
].slice(0, 80),
);
}, []);
const readThread = useCallback(
async (threadId: string, client = clientRef.current) => {
if (!client) {
return;
}
const response = await client.readThread({ threadId, includeTurns: true });
setSelectedThread(response.thread);
},
[],
);
const refreshThreads = useCallback(
async (client = clientRef.current) => {
if (!client) {
return;
}
const response = await client.listThreads({
limit: 60,
sortKey: "updated_at",
sortDirection: "desc",
archived: false,
sourceKinds: [],
useStateDbOnly: false,
});
setThreads(response.data);
const nextSelected =
selectedThreadId ??
response.data.find((thread) => thread.status.type !== "notLoaded")?.id ??
response.data[0]?.id;
if (nextSelected) {
setSelectedThreadId(nextSelected);
await readThread(nextSelected, client);
}
},
[readThread, selectedThreadId],
);
const refreshAccount = useCallback(async (client = clientRef.current) => {
if (!client) {
return;
}
try {
setAccount(await client.getAccount({ refreshToken: false }));
} catch {
setAccount(undefined);
}
}, []);
const refreshCurrent = useCallback(async () => {
const client = clientRef.current;
if (!client) {
return;
}
setBusyAction("refresh");
try {
await Promise.all([
refreshThreads(client),
refreshAccount(client),
selectedThreadId ? readThread(selectedThreadId, client) : undefined,
]);
} catch (refreshError) {
setError(errorMessage(refreshError));
} finally {
setBusyAction(undefined);
}
}, [readThread, refreshAccount, refreshThreads, selectedThreadId]);
const handleNotification = useCallback(
(message: JsonRpcNotification) => {
appendEvent({
kind: "notification",
title: message.method,
body: previewJson(message.params, 900),
});
const threadId = notificationThreadId(message);
if (threadId) {
if (!selectedThreadId || selectedThreadId === threadId) {
setSelectedThreadId(threadId);
void readThread(threadId).catch((readError) =>
setError(errorMessage(readError)),
);
}
void refreshThreads().catch((refreshError) =>
setError(errorMessage(refreshError)),
);
}
},
[appendEvent, readThread, refreshThreads, selectedThreadId],
);
const connect = useCallback(async () => {
const url = wsUrl.trim();
if (!url) {
setError("WebSocket URL is required");
setStatus("error");
return;
}
clientRef.current?.close();
const client = new CodexAppServerClient({
webSocketTransportOptions: { url, requestTimeoutMs: 90_000 },
clientName: "bare-web",
clientTitle: "Codex Bare Web",
clientVersion: "0.1.0",
});
clientRef.current = client;
client.on("notification", handleNotification);
client.on("request", (message: JsonRpcRequest) => {
appendEvent({
kind: "request",
title: message.method,
body: previewJson(message.params, 900),
});
});
client.on("error", (eventError: unknown) => {
appendEvent({
kind: "error",
title: "transport error",
body: errorMessage(eventError),
});
setError(errorMessage(eventError));
setStatus("error");
});
client.on("close", (code: number, reason: string) => {
appendEvent({
kind: "control",
title: "closed",
body: [code, reason].filter(Boolean).join(" "),
});
if (clientRef.current === client) {
setConnectedUrl(undefined);
setStatus("disconnected");
}
});
setStatus("connecting");
setError(undefined);
try {
await client.connect();
window.localStorage.setItem("codex-bare.ws-url", url);
setConnectedUrl(url);
setStatus("connected");
appendEvent({ kind: "control", title: "connected", body: url });
await Promise.all([refreshThreads(client), refreshAccount(client)]);
} catch (connectError) {
if (clientRef.current === client) {
clientRef.current = null;
setConnectedUrl(undefined);
setStatus("error");
}
client.close();
setError(errorMessage(connectError));
}
}, [
appendEvent,
handleNotification,
refreshAccount,
refreshThreads,
wsUrl,
]);
const disconnect = useCallback(() => {
clientRef.current?.close();
clientRef.current = null;
setConnectedUrl(undefined);
setStatus("disconnected");
appendEvent({ kind: "control", title: "disconnected" });
}, [appendEvent]);
useEffect(() => () => clientRef.current?.close(), []);
const selectThread = async (threadId: string) => {
setSelectedThreadId(threadId);
setBusyAction("read");
try {
await readThread(threadId);
} catch (readError) {
setError(errorMessage(readError));
} finally {
setBusyAction(undefined);
}
};
const sendPrompt = async (event: FormEvent) => {
event.preventDefault();
const client = clientRef.current;
const text = prompt.trim();
if (!client || !text) {
return;
}
setBusyAction("send");
setError(undefined);
try {
let threadId = selectedThreadId;
if (!threadId) {
const started = await client.startThread({
cwd: optionalText(cwd),
experimentalRawEvents: false,
persistExtendedHistory: false,
});
threadId = started.thread.id;
setSelectedThreadId(threadId);
setSelectedThread(started.thread);
}
await client.startTurn({
threadId,
input: [{ type: "text", text, text_elements: [] }],
cwd: optionalText(cwd),
});
setPrompt("");
await Promise.all([refreshThreads(client), readThread(threadId, client)]);
} catch (sendError) {
setError(errorMessage(sendError));
} finally {
setBusyAction(undefined);
}
};
const interruptTurn = async () => {
const client = clientRef.current;
const turn = activeTurn(selectedThread);
if (!client || !selectedThreadId || !turn) {
return;
}
setBusyAction("interrupt");
try {
await client.interruptTurn({ threadId: selectedThreadId, turnId: turn.id });
await readThread(selectedThreadId, client);
} catch (interruptError) {
setError(errorMessage(interruptError));
} finally {
setBusyAction(undefined);
}
};
const copyThreadId = async () => {
if (selectedThreadId && navigator.clipboard) {
await navigator.clipboard.writeText(selectedThreadId);
}
};
const selectedItems = useMemo(
() => selectedThread?.turns.flatMap((turn) => turn.items) ?? [],
[selectedThread],
);
const runningTurn = activeTurn(selectedThread);
const connected = status === "connected";
return (
<div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border bg-background/95">
<div className="mx-auto flex max-w-[1500px] flex-col gap-3 px-4 py-3 md:flex-row md:items-center">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<TerminalSquare className="size-5 text-primary" />
<h1 className="truncate text-base font-semibold">Codex Bare</h1>
</div>
<p className="truncate text-xs text-muted-foreground">
{connectedUrl ?? "No app-server connection"}
</p>
</div>
<form
className="grid gap-2 md:flex md:min-w-[620px] md:items-center"
onSubmit={(event) => {
event.preventDefault();
void connect();
}}
>
<input
className="h-9 min-w-0 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 md:flex-1"
onChange={(event) => setWsUrl(event.target.value)}
placeholder="ws://127.0.0.1:3585"
value={wsUrl}
/>
<div className="flex gap-2">
<Button
className="flex-1 md:flex-none"
disabled={status === "connecting"}
size="sm"
type="submit"
>
{status === "connecting" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Plug className="size-4" />
)}
Connect
</Button>
<Button
disabled={!clientRef.current}
onClick={disconnect}
size="sm"
type="button"
variant="outline"
>
<Unplug className="size-4" />
Disconnect
</Button>
</div>
</form>
</div>
</header>
<main className="mx-auto grid max-w-[1500px] gap-4 px-4 py-4 lg:grid-cols-[320px_minmax(0,1fr)_340px]">
<aside className="space-y-4">
<Panel
action={
<Button
disabled={!connected || busyAction === "refresh"}
onClick={() => void refreshCurrent()}
size="icon-sm"
title="Refresh"
variant="ghost"
>
<RefreshCw
className={
busyAction === "refresh"
? "size-4 animate-spin"
: "size-4"
}
/>
</Button>
}
title="Threads"
>
<div className="space-y-2">
<Button
className="w-full"
disabled={!connected}
onClick={() => {
setSelectedThreadId(undefined);
setSelectedThread(undefined);
}}
size="sm"
variant={!selectedThreadId ? "default" : "outline"}
>
New Thread
</Button>
<div className="max-h-[52vh] space-y-1 overflow-auto pr-1">
{threads.map((thread) => (
<button
className={cx(
"w-full rounded-md border px-3 py-2 text-left text-sm transition-colors",
thread.id === selectedThreadId
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted",
)}
key={thread.id}
onClick={() => void selectThread(thread.id)}
type="button"
>
<span className="block truncate font-medium">
{thread.name || thread.preview || compactId(thread.id)}
</span>
<span
className={cx(
"mt-1 block truncate text-xs",
thread.id === selectedThreadId
? "text-primary-foreground/75"
: "text-muted-foreground",
)}
>
{threadStatusText(thread.status)} / {compactPath(thread.cwd)}
</span>
</button>
))}
{connected && threads.length === 0 ? (
<EmptyState>No threads</EmptyState>
) : null}
{!connected ? <EmptyState>Disconnected</EmptyState> : null}
</div>
</div>
</Panel>
<Panel title="Account">
<dl className="grid gap-2 text-sm">
<Meta label="Status" value={statusLabel(status)} />
<Meta
label="Mode"
value={accountMode(account) ?? (connected ? "unknown" : "offline")}
/>
<Meta
label="Plan"
value={accountPlan(account) ?? "unknown"}
/>
</dl>
</Panel>
</aside>
<section className="min-w-0 space-y-4">
{error ? (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span className="break-words">{error}</span>
</div>
) : null}
<Panel
action={
<div className="flex gap-1">
<Button
disabled={!selectedThreadId}
onClick={() => void copyThreadId()}
size="icon-sm"
title="Copy thread id"
variant="ghost"
>
<Copy className="size-4" />
</Button>
<Button
disabled={!runningTurn || busyAction === "interrupt"}
onClick={() => void interruptTurn()}
size="icon-sm"
title="Interrupt"
variant="ghost"
>
<Square className="size-4" />
</Button>
</div>
}
title={selectedThread?.name || selectedThread?.preview || "Thread"}
>
<div className="mb-3 grid gap-2 text-sm md:grid-cols-3">
<InfoPill
label="Thread"
value={compactId(selectedThreadId)}
/>
<InfoPill
label="Status"
value={
selectedThread
? threadStatusText(selectedThread.status)
: "new"
}
/>
<InfoPill
label="Cwd"
value={selectedThread ? compactPath(selectedThread.cwd) : "unset"}
/>
</div>
<div className="max-h-[58vh] min-h-[360px] overflow-auto rounded-md border border-border bg-muted/30 p-3">
{selectedItems.length > 0 ? (
<div className="space-y-3">
{selectedItems.map((item) => (
<ThreadItemView item={item} key={item.id} />
))}
</div>
) : (
<div className="flex min-h-[320px] items-center justify-center text-sm text-muted-foreground">
{selectedThreadId ? "No loaded items" : "New thread"}
</div>
)}
</div>
</Panel>
<form className="rounded-md border border-border bg-card p-3" onSubmit={sendPrompt}>
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_220px_auto]">
<textarea
className="min-h-24 resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30"
disabled={!connected || busyAction === "send"}
onChange={(event) => setPrompt(event.target.value)}
placeholder="Send a message to Codex"
value={prompt}
/>
<input
className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30"
disabled={!connected || busyAction === "send"}
onChange={(event) => setCwd(event.target.value)}
placeholder="cwd"
value={cwd}
/>
<Button
className="h-10"
disabled={!connected || !prompt.trim() || busyAction === "send"}
type="submit"
>
{busyAction === "send" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
{selectedThreadId ? "Send" : "Start"}
</Button>
</div>
</form>
</section>
<aside>
<Panel title="Events">
<div className="max-h-[78vh] space-y-2 overflow-auto pr-1">
{eventLog.map((event) => (
<div
className="rounded-md border border-border bg-background px-3 py-2 text-xs"
key={event.id}
>
<div className="mb-1 flex items-center justify-between gap-2">
<span className="truncate font-medium">{event.title}</span>
<span className="shrink-0 text-muted-foreground">
{formatTime(event.at)}
</span>
</div>
{event.body ? (
<pre className="max-h-36 overflow-auto whitespace-pre-wrap break-words text-muted-foreground">
{event.body}
</pre>
) : null}
</div>
))}
{eventLog.length === 0 ? <EmptyState>No events</EmptyState> : null}
</div>
</Panel>
</aside>
</main>
</div>
);
}
function Panel({
action,
children,
title,
}: {
action?: ReactNode;
children: ReactNode;
title: string;
}) {
return (
<section className="rounded-md border border-border bg-card">
<div className="flex min-h-12 items-center justify-between gap-2 border-b border-border px-3">
<h2 className="min-w-0 truncate text-sm font-semibold">{title}</h2>
{action}
</div>
<div className="p-3">{children}</div>
</section>
);
}
function ThreadItemView({ item }: { item: v2.ThreadItem }) {
const { title, body, tone } = itemDisplay(item);
return (
<article
className={cx(
"rounded-md border bg-background px-3 py-2",
tone === "user"
? "border-primary/25"
: tone === "tool"
? "border-accent/70"
: "border-border",
)}
>
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="truncate font-medium uppercase tracking-normal">{title}</span>
<span className="shrink-0">{compactId(item.id, 4)}</span>
</div>
<pre className="whitespace-pre-wrap break-words text-sm leading-6">{body}</pre>
</article>
);
}
function itemDisplay(item: v2.ThreadItem): {
title: string;
body: string;
tone: "assistant" | "tool" | "user";
} {
switch (item.type) {
case "userMessage":
return {
title: "user",
body: item.content.map(userInputText).join("\n\n"),
tone: "user",
};
case "agentMessage":
return { title: "assistant", body: item.text, tone: "assistant" };
case "reasoning":
return {
title: "reasoning",
body: [...item.summary, ...item.content].join("\n"),
tone: "assistant",
};
case "plan":
return { title: "plan", body: item.text, tone: "assistant" };
case "commandExecution":
return {
title: `command / ${item.status}`,
body: [item.command, item.aggregatedOutput].filter(Boolean).join("\n\n"),
tone: "tool",
};
case "fileChange":
return {
title: `file change / ${item.status}`,
body: previewJson(item.changes, 1600),
tone: "tool",
};
case "mcpToolCall":
return {
title: `mcp / ${item.server}.${item.tool}`,
body: previewJson(
{ status: item.status, arguments: item.arguments, result: item.result, error: item.error },
1600,
),
tone: "tool",
};
case "dynamicToolCall":
return {
title: `tool / ${[item.namespace, item.tool].filter(Boolean).join(".")}`,
body: previewJson(
{
status: item.status,
arguments: item.arguments,
contentItems: item.contentItems,
success: item.success,
},
1600,
),
tone: "tool",
};
case "webSearch":
return { title: "web search", body: item.query, tone: "tool" };
case "imageView":
return { title: "image", body: item.path, tone: "tool" };
case "imageGeneration":
return {
title: `image generation / ${item.status}`,
body: [item.revisedPrompt, item.savedPath ?? item.result]
.filter(Boolean)
.join("\n\n"),
tone: "tool",
};
default:
return { title: item.type, body: previewJson(item, 1600), tone: "tool" };
}
}
function InfoPill({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-md border border-border bg-background px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="truncate text-sm font-medium">{value}</div>
</div>
);
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-[90px_minmax(0,1fr)] gap-2">
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0 truncate">{value}</dd>
</div>
);
}
function EmptyState({ children }: { children: ReactNode }) {
return (
<div className="rounded-md border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
{children}
</div>
);
}
function statusLabel(status: ConnectionStatus) {
if (status === "connected") {
return "connected";
}
if (status === "connecting") {
return "connecting";
}
if (status === "error") {
return "error";
}
return "disconnected";
}
function activeTurn(thread: v2.Thread | undefined) {
if (!thread) {
return null;
}
for (let index = thread.turns.length - 1; index >= 0; index -= 1) {
const turn = thread.turns[index];
if (turn?.status === "inProgress") {
return turn;
}
}
return null;
}
function threadStatusText(status: v2.ThreadStatus) {
return status.type === "active"
? `active${status.activeFlags.length ? `/${status.activeFlags.join(",")}` : ""}`
: status.type;
}
function userInputText(input: v2.UserInput) {
switch (input.type) {
case "text":
return input.text;
case "image":
return input.url;
case "localImage":
return input.path;
case "skill":
return `${input.name} ${input.path}`;
case "mention":
return `${input.name} ${input.path}`;
default:
return previewJson(input, 500);
}
}
function notificationThreadId(message: JsonRpcNotification) {
const params = record(message.params);
const direct = stringValue(params.threadId);
if (direct) {
return direct;
}
const thread = record(params.thread);
return stringValue(thread.id);
}
function accountMode(account: v2.GetAccountResponse | undefined) {
const value = account as unknown;
const item = record(value);
return (
stringValue(item.authMode) ??
stringValue(record(item.account).type) ??
stringValue(record(item.account).authMode)
);
}
function accountPlan(account: v2.GetAccountResponse | undefined) {
const value = account as unknown;
const item = record(value);
return stringValue(item.planType) ?? stringValue(record(item.account).planType);
}
function optionalText(value: string) {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function compactPath(path: string | undefined) {
if (!path) {
return "none";
}
const parts = path.split("/").filter(Boolean);
return parts.length > 2 ? `.../${parts.slice(-2).join("/")}` : path;
}
function compactId(value: string | undefined, edge = 6) {
if (!value) {
return "none";
}
if (value.length <= edge * 2 + 1) {
return value;
}
return `${value.slice(0, edge)}...${value.slice(-edge)}`;
}
function formatTime(value: string) {
return new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(new Date(value));
}
function previewJson(value: unknown, maxLength = 900) {
const text =
typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? "";
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
}
function errorMessage(error: unknown) {
if (error instanceof JsonRpcError) {
return `${error.message} (${error.code})`;
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function stringValue(value: unknown) {
return typeof value === "string" && value ? value : undefined;
}
function initialWsUrl() {
return window.localStorage.getItem("codex-bare.ws-url") ?? defaultWsUrl;
}
function defaultProxiedWsUrl() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/__codex-app-server`;
}
function cx(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(" ");
}

View file

@ -0,0 +1,10 @@
import { useEffect, type ReactNode } from "react";
export function ThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
document.documentElement.classList.add("dark");
return () => document.documentElement.classList.remove("dark");
}, []);
return children;
}

12
apps/web/src/main.tsx Normal file
View file

@ -0,0 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@workspace/ui/globals.css";
import { App } from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

1
apps/web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@peezy-tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy-tech/codex-flows/*": ["../../packages/codex-client/src/*"],
"@workspace/ui/*": ["../../packages/ui/src/*"]
}
},
"include": ["src"]
}

16
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@peezy-tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy-tech/codex-flows/*": ["../../packages/codex-client/src/*"],
"@workspace/ui/*": ["../../packages/ui/src/*"]
}
}
}

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

36
apps/web/vite.config.ts Normal file
View file

@ -0,0 +1,36 @@
import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
const allowedHosts = (process.env.VITE_ALLOWED_HOSTS ?? "")
.split(",")
.map((host) => host.trim())
.filter(Boolean);
const codexAppServerTarget =
process.env.VITE_CODEX_APP_SERVER_PROXY_TARGET ?? "ws://127.0.0.1:3585";
export default defineConfig({
base: process.env.VITE_BASE_PATH ?? "/",
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
allowedHosts: allowedHosts.length > 0 ? allowedHosts : undefined,
proxy: {
"/__codex-app-server": {
target: codexAppServerTarget,
ws: true,
rewrite: () => "/",
configure: (proxy) => {
proxy.on("proxyReqWs", (proxyReq) => {
proxyReq.removeHeader("origin");
});
},
},
},
},
});