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

View file

@ -0,0 +1,43 @@
name: Publish @peezy-tech/codex-flows
on:
workflow_dispatch:
inputs:
confirm_package:
description: "Type @peezy-tech/codex-flows to publish"
required: true
type: string
permissions:
contents: read
id-token: write
jobs:
publish:
if: inputs.confirm_package == '@peezy-tech/codex-flows'
runs-on: ubuntu-latest
environment: npm-publish
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Release check
run: bun run --filter @peezy-tech/codex-flows release:check
- name: Publish
working-directory: packages/codex-client
run: npm publish --access public

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.local
.DS_Store
*.log

25
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,25 @@
# Code of Conduct
This project expects respectful, direct, and constructive participation.
## Expected Behavior
- Assume good intent while staying precise about technical risks.
- Keep discussion focused on the work and its impact.
- Be clear when disagreeing, and explain the reasoning.
- Respect maintainers' time by providing reproducible reports and scoped changes.
## Unacceptable Behavior
- Harassment, threats, or personal attacks.
- Discriminatory language or behavior.
- Publishing private information without permission.
- Repeated disruption of issues, pull requests, or discussions.
## Enforcement
Maintainers may remove comments, close issues, reject contributions, or block
participants who violate this code of conduct.
If you need to report a conduct issue, contact a maintainer privately through
the repository's available private contact or moderation channel.

18
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,18 @@
# Contributing
Install dependencies and run the checks before submitting changes:
```bash
bun install
bun run build
bun run test
```
Keep changes scoped to the bare package set:
- `apps/web`
- `packages/codex-client`
- `packages/ui`
Avoid reintroducing service, workspace, gateway, job, or host setup code on this
branch.

176
LICENSE Normal file
View file

@ -0,0 +1,176 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

123
README.md Normal file
View file

@ -0,0 +1,123 @@
# Codex Bare
Thin browser UI plus TypeScript client for `codex app-server`.
This branch intentionally drops the workspace service, runtime, gateways, jobs,
delegation, and host setup layer. The remaining source is:
- `apps/web`: React/Vite UI that connects directly to a Codex app-server WebSocket.
- `apps/cli`: Bun CLI that sends JSON-RPC actions to a listening Codex app-server.
- `packages/codex-client`: JSON-RPC client, app-server transports, flow helpers, and generated protocol types.
- `packages/ui`: small shared UI primitives and styling.
## Run
Install dependencies:
```bash
bun install
```
Start a Codex app-server WebSocket in a separate shell:
```bash
codex app-server --listen ws://127.0.0.1:3585 --enable apps --enable hooks
```
Start the web app:
```bash
bun run dev
```
In development, the web app defaults to a same-origin Vite WebSocket proxy at
`/__codex-app-server`, which forwards to `ws://127.0.0.1:3585`. This avoids
browser `Origin` header rejections from the app-server, which can show up in
WSL and other browser-to-localhost setups.
Set `VITE_CODEX_APP_SERVER_PROXY_TARGET` to proxy to a different app-server
URL. Set `VITE_CODEX_APP_SERVER_WS_URL` only when you explicitly want the
browser to connect directly to an app-server WebSocket.
Send a command to the running app-server:
```bash
bun apps/cli/src/index.ts thread/list '{"limit": 20, "sourceKinds": []}'
echo '{"refreshToken": false}' | bun apps/cli/src/index.ts account/read
```
List available actions:
```bash
bun apps/cli/src/index.ts actions
```
## Build And Test
```bash
bun run build
bun run test
```
`bun run test` currently runs the `@peezy-tech/codex-flows` transport tests.
## Publishing
The public home for this monorepo is `peezy-tech/codex-flows`. When seeding that
repo, copy this working tree without `.git`, initialize a fresh git history, and
push it to the new public GitHub repo.
`@peezy-tech/codex-flows` is published from `packages/codex-client`.
Before the first publish:
```bash
bun run --filter @peezy-tech/codex-flows release:check
```
Because the npm package does not exist yet, bootstrap the first version with a
human npm session or short-lived npm token from the public repo checkout:
```bash
cd packages/codex-client
npm publish --access public
```
After the package exists, configure npm trusted publishing for:
- Package: `@peezy-tech/codex-flows`
- Repository: `peezy-tech/codex-flows`
- Workflow: `.github/workflows/publish-codex-flows.yml`
- Environment: `npm-publish`
Future publishes should use the GitHub Actions workflow and should not require
an npm token.
## Packages
### `@peezy-tech/codex-flows`
The low-level app-server client package. It exports:
- `@peezy-tech/codex-flows`: Node/Bun entry with stdio and WebSocket transports.
- `@peezy-tech/codex-flows/browser`: browser entry with WebSocket transport only.
- `@peezy-tech/codex-flows/flows`: framework-agnostic helpers for app servers that want to start Codex-backed workflows.
- `@peezy-tech/codex-flows/rpc`: JSON-RPC helpers and types.
- `@peezy-tech/codex-flows/generated`: generated Codex app-server protocol types.
### `web`
The browser app imports `@peezy-tech/codex-flows/browser`, opens a direct WebSocket
connection, lists threads, starts turns, interrupts running turns, and renders
thread items and live app-server events.
### `cli`
CLI package for piping JSON params into app-server JSON-RPC actions over a
running WebSocket listener. It defaults to `ws://127.0.0.1:3585`, respects
`CODEX_WORKSPACE_APP_SERVER_WS_URL`, supports `--url`, and lists available
actions from the generated app-server action list.
### `@workspace/ui`
Shared Tailwind/shadcn-compatible UI primitives used by the web app.

8
SECURITY.md Normal file
View file

@ -0,0 +1,8 @@
# Security
`codex-bare` is a browser UI for a Codex app-server WebSocket. It does not add
authentication, authorization, persistence, or request filtering in front of the
app-server.
Keep the app-server bound to localhost or another trusted network boundary. Do
not expose the app-server WebSocket directly to the public internet.

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");
});
},
},
},
},
});

1120
bun.lock Normal file

File diff suppressed because it is too large Load diff

2
bunfig.toml Normal file
View file

@ -0,0 +1,2 @@
[install]
auto = "disable"

32
mprocs.yaml Normal file
View file

@ -0,0 +1,32 @@
procs:
codex-remote-control:
shell: >
bash -lc 'set -o pipefail;
set -a;
[ ! -f .env ] || source .env;
set +a;
codex app-server
--listen "${CODEX_WORKSPACE_APP_SERVER_WS_URL:-ws://127.0.0.1:3585}"
--enable apps
--enable hooks
--enable remote_control
2>&1 | bun ./apps/discord-bridge/src/pretty-log.ts --name codex-remote-control'
autostart: true
autorestart: false
discord-bridge:
shell: >
bash -lc 'set -o pipefail;
set -a;
[ ! -f .env ] || source .env;
set +a;
bun ./apps/discord-bridge/src/index.ts
--log-level warn
--console-output messages
--approval-policy never
--sandbox danger-full-access
--progress-mode commentary
--app-server-url "${CODEX_WORKSPACE_APP_SERVER_WS_URL:-ws://127.0.0.1:3585}"
2> >(bun ./apps/discord-bridge/src/pretty-log.ts --name discord-bridge >&2)'
autostart: true
autorestart: false

47
package.json Normal file
View file

@ -0,0 +1,47 @@
{
"name": "codex-bare",
"version": "0.1.0",
"description": "Thin web UI and TypeScript client for Codex app-server.",
"private": true,
"license": "Apache-2.0",
"packageManager": "bun@1.3.11",
"engines": {
"bun": ">=1.3.11"
},
"workspaces": {
"packages": [
"apps/*",
"packages/*"
],
"catalog": {
"@base-ui/react": "^1.4.1",
"@tailwindcss/vite": "^4.1.18",
"@types/bun": "^1.3.13",
"@types/node": "^22.10.10",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"discord.js": "^14.22.1",
"lucide-react": "^1.14.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.2",
"vite": "^7.3.2"
}
},
"scripts": {
"build": "bun run --workspaces build",
"check:types": "bun run --workspaces check:types",
"dev": "bun run --filter web dev",
"dev:web": "bun run --filter web dev",
"start": "bun run --filter web preview",
"start:discord:debug:commentary": "bun run --filter codex-discord-bridge start:debug:commentary",
"test": "bun run --filter @peezy-tech/codex-flows test && bun run --filter codex-app-cli test && bun run --filter codex-discord-bridge test"
}
}

View file

@ -0,0 +1,124 @@
# @peezy-tech/codex-flows
Workspace package for talking to `codex app-server`.
This package owns the low-level JSON-RPC client, transports, framework-agnostic flow helpers, and generated Codex app-server protocol types.
## Exports
- `@peezy-tech/codex-flows`
- `CodexAppServerClient`
- `CodexStdioTransport`
- `CodexWebSocketTransport`
- JSON-RPC helpers and types
- `@peezy-tech/codex-flows/browser`
- browser-safe `CodexAppServerClient`
- `CodexWebSocketTransport`
- JSON-RPC helpers and types
- `@peezy-tech/codex-flows/flows`
- `CodexFlowClient`
- `createCodexFlowClient`
- prompt/input normalization and optional turn completion waiting
- `@peezy-tech/codex-flows/rpc`
- JSON-RPC message types and parsing helpers
- `@peezy-tech/codex-flows/generated`
- generated Codex app-server protocol types
- `@peezy-tech/codex-flows/generated/*`
- generated per-type modules
## Transports
`CodexAppServerClient` defaults to a stdio transport that starts `codex app-server` when no explicit transport is provided.
It can also connect to an existing WebSocket app-server when `CODEX_WORKSPACE_APP_SERVER_WS_URL` is set, or when `webSocketTransportOptions.url` is passed.
```ts
import { CodexAppServerClient } from "@peezy-tech/codex-flows";
const client = new CodexAppServerClient();
await client.connect();
const threads = await client.listThreads({});
client.close();
```
Browser entry:
```ts
import { CodexAppServerClient } from "@peezy-tech/codex-flows/browser";
const client = new CodexAppServerClient({
webSocketTransportOptions: { url: "ws://127.0.0.1:3585" },
});
await client.connect();
```
Flow helpers:
```ts
import { createCodexFlowClient } from "@peezy-tech/codex-flows/flows";
const codex = createCodexFlowClient({
appServerUrl: "ws://127.0.0.1:3585",
});
const result = await codex.startFlow({
cwd: "/path/to/app",
prompt: "Run the app-specific Codex workflow.",
approvalPolicy: "never",
sandbox: "danger-full-access",
wait: false,
});
console.log(result.threadId, result.turnId);
```
## Scripts
```bash
bun run --filter @peezy-tech/codex-flows build
bun run --filter @peezy-tech/codex-flows check:types
bun run --filter @peezy-tech/codex-flows test
bun run --filter @peezy-tech/codex-flows pack:dry-run
bun run --filter @peezy-tech/codex-flows release:check
```
`build` emits ESM JavaScript, source maps, and declaration files into `dist`.
## Install
After publishing, install the package from npm:
```bash
bun add @peezy-tech/codex-flows
```
or:
```bash
npm install @peezy-tech/codex-flows
```
## Publishing
Run the release check before publishing:
```bash
bun run --filter @peezy-tech/codex-flows release:check
```
The release check runs package tests, type checking, a clean `dist` build, and `npm pack --dry-run`. Review the pack output before publishing so only `dist`, `README.md`, and package metadata are included.
For the first publish, use a human npm session or short-lived npm token from the public `peezy-tech/codex-flows` repo checkout:
```bash
cd packages/codex-client
npm publish --access public
```
After `@peezy-tech/codex-flows` exists on npm, configure trusted publishing for `.github/workflows/publish-codex-flows.yml` in the public `peezy-tech/codex-flows` repo. Future publishes should run through GitHub Actions without an npm token.
## Notes
Generated protocol files live in `src/app-server/generated`. Keep handwritten client and transport code outside that generated tree.

View file

@ -0,0 +1,71 @@
{
"name": "@peezy-tech/codex-flows",
"version": "0.1.0",
"description": "Codex app-server JSON-RPC client, flow helpers, and generated protocol types.",
"type": "module",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/peezy-tech/codex-flows.git",
"directory": "packages/codex-client"
},
"keywords": [
"codex",
"codex-app-server",
"json-rpc",
"flows"
],
"sideEffects": false,
"types": "./dist/index.d.ts",
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./browser": {
"types": "./dist/browser.d.ts",
"import": "./dist/browser.js"
},
"./flows": {
"types": "./dist/app-server/flows.d.ts",
"import": "./dist/app-server/flows.js"
},
"./rpc": {
"types": "./dist/app-server/rpc.d.ts",
"import": "./dist/app-server/rpc.js"
},
"./generated": {
"types": "./dist/app-server/generated/index.d.ts",
"import": "./dist/app-server/generated/index.js"
},
"./generated/*": {
"types": "./dist/app-server/generated/*.d.ts",
"import": "./dist/app-server/generated/*.js"
}
},
"engines": {
"bun": ">=1.3.11"
},
"scripts": {
"build": "bun run clean && tsc -p tsconfig.build.json",
"check:types": "tsc --noEmit",
"clean": "rm -rf dist",
"pack:dry-run": "bun run scripts/pack-dry-run.ts",
"prepack": "bun run build",
"release:check": "bun run test && bun run check:types && bun run build && bun run smoke:exports && bun run pack:dry-run",
"smoke:exports": "bun run scripts/smoke-exports.ts",
"test": "bun test test/*.test.ts"
},
"devDependencies": {
"@types/bun": "^1.3.13",
"@types/node": "^22.10.10",
"typescript": "^5.9.2"
}
}

View file

@ -0,0 +1,67 @@
type PackFile = {
path: string;
size: number;
};
type PackResult = {
name: string;
version: string;
filename: string;
files: PackFile[];
unpackedSize: number;
size: number;
};
const proc = Bun.spawn(["npm", "pack", "--dry-run", "--json"], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) {
process.stderr.write(stderr);
process.stderr.write(stdout);
process.exit(exitCode);
}
const results = JSON.parse(stdout) as PackResult[];
const result = results[0];
if (!result) {
throw new Error("npm pack did not return package metadata");
}
const byTopLevel = new Map<string, number>();
for (const file of result.files) {
const [topLevel = file.path] = file.path.split("/");
byTopLevel.set(topLevel, (byTopLevel.get(topLevel) ?? 0) + 1);
}
const topLevelSummary = [...byTopLevel.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, count]) => `${name}: ${count}`)
.join(", ");
console.log(`${result.name}@${result.version}`);
console.log(`tarball: ${result.filename}`);
console.log(`files: ${result.files.length} (${topLevelSummary})`);
console.log(`package size: ${formatBytes(result.size)}`);
console.log(`unpacked size: ${formatBytes(result.unpackedSize)}`);
function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
const kib = bytes / 1024;
if (kib < 1024) {
return `${kib.toFixed(1)} KiB`;
}
return `${(kib / 1024).toFixed(1)} MiB`;
}

View file

@ -0,0 +1,18 @@
const checks = [
["@peezy-tech/codex-flows", ["CodexAppServerClient"]],
["@peezy-tech/codex-flows/browser", ["CodexAppServerClient"]],
["@peezy-tech/codex-flows/flows", ["CodexFlowClient", "createCodexFlowClient"]],
["@peezy-tech/codex-flows/rpc", ["JsonRpcError"]],
["@peezy-tech/codex-flows/generated", ["v2"]],
] as const;
for (const [specifier, expectedExports] of checks) {
const module = await import(specifier);
for (const exportName of expectedExports) {
if (!(exportName in module)) {
throw new Error(`${specifier} is missing export ${exportName}`);
}
}
}
console.log("export smoke test passed");

View file

@ -0,0 +1,133 @@
import type { v2 } from "./generated/index.ts";
import { CodexEventEmitter } from "./events.ts";
import type { JsonRpcId } from "./rpc.ts";
import {
CodexWebSocketTransport,
type CodexWebSocketTransportOptions,
} from "./websocket-transport.ts";
export type CodexBrowserAppServerTransport = CodexEventEmitter & {
readonly requestTimeoutMs: number;
start(): void;
close(): void;
request<T = unknown>(method: string, params?: unknown): Promise<T>;
notify(method: string, params?: unknown): void;
respond(id: JsonRpcId, result: unknown): void;
respondError(id: JsonRpcId, code: number, message: string, data?: unknown): void;
};
export type CodexBrowserAppServerClientOptions = {
transport?: CodexBrowserAppServerTransport;
webSocketTransportOptions?: CodexWebSocketTransportOptions;
clientName?: string;
clientTitle?: string;
clientVersion?: string;
};
export class CodexBrowserAppServerClient extends CodexEventEmitter {
readonly transport: CodexBrowserAppServerTransport;
#clientName: string;
#clientTitle: string | null;
#clientVersion: string;
#connected = false;
constructor(options: CodexBrowserAppServerClientOptions = {}) {
super();
const url = options.webSocketTransportOptions?.url;
if (!options.transport && !url) {
throw new Error("A Codex app-server WebSocket URL is required");
}
this.transport =
options.transport ??
new CodexWebSocketTransport({
url: url!,
requestTimeoutMs: options.webSocketTransportOptions?.requestTimeoutMs,
});
this.#clientName = options.clientName ?? "bare-web";
this.#clientTitle = options.clientTitle ?? "Codex Bare Web";
this.#clientVersion = options.clientVersion ?? "0.1.0";
this.transport.on("notification", (message) =>
this.emit("notification", message),
);
this.transport.on("request", (message) => this.emit("request", message));
this.transport.on("close", (code, reason) => this.emit("close", code, reason));
this.transport.on("error", (error) => this.emit("error", error));
}
async connect(): Promise<void> {
if (this.#connected) {
return;
}
this.transport.start();
await this.request("initialize", {
clientInfo: {
name: this.#clientName,
title: this.#clientTitle,
version: this.#clientVersion,
},
capabilities: {
experimentalApi: true,
},
});
this.transport.notify("initialized");
this.#connected = true;
}
close(): void {
this.#connected = false;
this.transport.close();
}
request<T = unknown>(method: string, params?: unknown): Promise<T> {
return this.transport.request<T>(method, params);
}
respond(id: JsonRpcId, result: unknown): void {
this.transport.respond(id, result);
}
respondError(id: JsonRpcId, code: number, message: string, data?: unknown): void {
this.transport.respondError(id, code, message, data);
}
startThread(
params: v2.ThreadStartParams,
): Promise<v2.ThreadStartResponse> {
return this.request<v2.ThreadStartResponse>("thread/start", params);
}
resumeThread(
params: v2.ThreadResumeParams,
): Promise<v2.ThreadResumeResponse> {
return this.request<v2.ThreadResumeResponse>("thread/resume", params);
}
listThreads(params: v2.ThreadListParams): Promise<v2.ThreadListResponse> {
return this.request<v2.ThreadListResponse>("thread/list", params);
}
readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse> {
return this.request<v2.ThreadReadResponse>("thread/read", params);
}
startTurn(params: v2.TurnStartParams): Promise<v2.TurnStartResponse> {
return this.request<v2.TurnStartResponse>("turn/start", params);
}
steerTurn(params: v2.TurnSteerParams): Promise<v2.TurnSteerResponse> {
return this.request<v2.TurnSteerResponse>("turn/steer", params);
}
interruptTurn(
params: v2.TurnInterruptParams,
): Promise<v2.TurnInterruptResponse> {
return this.request<v2.TurnInterruptResponse>("turn/interrupt", params);
}
getAccount(
params: v2.GetAccountParams = { refreshToken: false },
): Promise<v2.GetAccountResponse> {
return this.request<v2.GetAccountResponse>("account/read", params);
}
}

View file

@ -0,0 +1,244 @@
import type { v2 } from "./generated/index.ts";
import { CodexEventEmitter } from "./events.ts";
import type { JsonRpcId } from "./rpc.ts";
import {
CodexStdioTransport,
type CodexStdioTransportOptions,
} from "./stdio-transport.ts";
import {
CodexWebSocketTransport,
type CodexWebSocketTransportOptions,
} from "./websocket-transport.ts";
export type CodexAppServerTransport = CodexEventEmitter & {
readonly requestTimeoutMs: number;
start(): void;
close(): void;
request<T = unknown>(method: string, params?: unknown): Promise<T>;
notify(method: string, params?: unknown): void;
respond(id: JsonRpcId, result: unknown): void;
respondError(id: JsonRpcId, code: number, message: string, data?: unknown): void;
};
export type CodexAppServerClientOptions = {
transport?: CodexAppServerTransport;
transportOptions?: CodexStdioTransportOptions;
webSocketTransportOptions?: CodexWebSocketTransportOptions;
clientName?: string;
clientTitle?: string;
clientVersion?: string;
};
export class CodexAppServerClient extends CodexEventEmitter {
readonly transport: CodexAppServerTransport;
#clientName: string;
#clientTitle: string | null;
#clientVersion: string;
#connected = false;
constructor(options: CodexAppServerClientOptions = {}) {
super();
this.transport =
options.transport ?? defaultTransport(options);
this.#clientName = options.clientName ?? "@peezy-tech/codex-flows";
this.#clientTitle = options.clientTitle ?? "Codex Client";
this.#clientVersion = options.clientVersion ?? "0.1.0";
this.transport.on("notification", (message) =>
this.emit("notification", message),
);
this.transport.on("request", (message) => this.emit("request", message));
this.transport.on("stderr", (line) => this.emit("stderr", line));
this.transport.on("close", (code, signal) =>
this.emit("close", code, signal),
);
this.transport.on("error", (error) => this.emit("error", error));
}
async connect(): Promise<void> {
if (this.#connected) {
return;
}
this.transport.start();
await this.request("initialize", {
clientInfo: {
name: this.#clientName,
title: this.#clientTitle,
version: this.#clientVersion,
},
capabilities: {
experimentalApi: true,
},
});
this.transport.notify("initialized");
this.#connected = true;
}
close(): void {
this.#connected = false;
this.transport.close();
}
request<T = unknown>(method: string, params?: unknown): Promise<T> {
return this.transport.request<T>(method, params);
}
respond(id: JsonRpcId, result: unknown): void {
this.transport.respond(id, result);
}
respondError(id: JsonRpcId, code: number, message: string, data?: unknown): void {
this.transport.respondError(id, code, message, data);
}
startThread(
params: v2.ThreadStartParams,
): Promise<v2.ThreadStartResponse> {
return this.request<v2.ThreadStartResponse>("thread/start", params);
}
resumeThread(
params: v2.ThreadResumeParams,
): Promise<v2.ThreadResumeResponse> {
return this.request<v2.ThreadResumeResponse>("thread/resume", params);
}
forkThread(
params: v2.ThreadForkParams,
): Promise<v2.ThreadForkResponse> {
return this.request<v2.ThreadForkResponse>("thread/fork", params);
}
listThreads(params: v2.ThreadListParams): Promise<v2.ThreadListResponse> {
return this.request<v2.ThreadListResponse>("thread/list", params);
}
readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse> {
return this.request<v2.ThreadReadResponse>("thread/read", params);
}
listThreadTurns(
params: v2.ThreadTurnsListParams,
): Promise<v2.ThreadTurnsListResponse> {
return this.request<v2.ThreadTurnsListResponse>("thread/turns/list", params);
}
listThreadTurnItems(
params: v2.ThreadTurnsItemsListParams,
): Promise<v2.ThreadTurnsItemsListResponse> {
return this.request<v2.ThreadTurnsItemsListResponse>(
"thread/turns/items/list",
params,
);
}
setThreadName(params: v2.ThreadSetNameParams): Promise<v2.ThreadSetNameResponse> {
return this.request<v2.ThreadSetNameResponse>("thread/name/set", params);
}
startTurn(params: v2.TurnStartParams): Promise<v2.TurnStartResponse> {
return this.request<v2.TurnStartResponse>("turn/start", params);
}
steerTurn(params: v2.TurnSteerParams): Promise<v2.TurnSteerResponse> {
return this.request<v2.TurnSteerResponse>("turn/steer", params);
}
interruptTurn(
params: v2.TurnInterruptParams,
): Promise<v2.TurnInterruptResponse> {
return this.request<v2.TurnInterruptResponse>("turn/interrupt", params);
}
commandExec(params: v2.CommandExecParams): Promise<v2.CommandExecResponse> {
return this.request<v2.CommandExecResponse>("command/exec", params);
}
commandExecWrite(
params: v2.CommandExecWriteParams,
): Promise<v2.CommandExecWriteResponse> {
return this.request<v2.CommandExecWriteResponse>("command/exec/write", params);
}
commandExecTerminate(
params: v2.CommandExecTerminateParams,
): Promise<v2.CommandExecTerminateResponse> {
return this.request<v2.CommandExecTerminateResponse>(
"command/exec/terminate",
params,
);
}
setThreadGoal(
params: v2.ThreadGoalSetParams,
): Promise<v2.ThreadGoalSetResponse> {
return this.request<v2.ThreadGoalSetResponse>("thread/goal/set", params);
}
getThreadGoal(
params: v2.ThreadGoalGetParams,
): Promise<v2.ThreadGoalGetResponse> {
return this.request<v2.ThreadGoalGetResponse>("thread/goal/get", params);
}
clearThreadGoal(
params: v2.ThreadGoalClearParams,
): Promise<v2.ThreadGoalClearResponse> {
return this.request<v2.ThreadGoalClearResponse>("thread/goal/clear", params);
}
listPlugins(params: v2.PluginListParams): Promise<v2.PluginListResponse> {
return this.request<v2.PluginListResponse>("plugin/list", params);
}
readPlugin(params: v2.PluginReadParams): Promise<v2.PluginReadResponse> {
return this.request<v2.PluginReadResponse>("plugin/read", params);
}
listPluginShares(): Promise<v2.PluginShareListResponse> {
return this.request<v2.PluginShareListResponse>("plugin/share/list", {});
}
updatePluginShareTargets(
params: v2.PluginShareUpdateTargetsParams,
): Promise<v2.PluginShareUpdateTargetsResponse> {
return this.request<v2.PluginShareUpdateTargetsResponse>(
"plugin/share/updateTargets",
params,
);
}
readPluginSkill(
params: v2.PluginSkillReadParams,
): Promise<v2.PluginSkillReadResponse> {
return this.request<v2.PluginSkillReadResponse>("plugin/skill/read", params);
}
getAccountRateLimits(): Promise<v2.GetAccountRateLimitsResponse> {
return this.request<v2.GetAccountRateLimitsResponse>("account/rateLimits/read");
}
getAccount(
params: v2.GetAccountParams = { refreshToken: false },
): Promise<v2.GetAccountResponse> {
return this.request<v2.GetAccountResponse>("account/read", params);
}
}
function defaultTransport(
options: CodexAppServerClientOptions,
): CodexAppServerTransport {
const webSocketUrl =
options.webSocketTransportOptions?.url ??
process.env.CODEX_WORKSPACE_APP_SERVER_WS_URL;
if (webSocketUrl) {
return new CodexWebSocketTransport({
url: webSocketUrl,
requestTimeoutMs:
options.webSocketTransportOptions?.requestTimeoutMs ??
options.transportOptions?.requestTimeoutMs,
});
}
return new CodexStdioTransport(options.transportOptions);
}

View file

@ -0,0 +1,39 @@
type Listener = (...args: any[]) => void;
export class CodexEventEmitter {
#listeners = new Map<string, Set<Listener>>();
on(event: string, listener: Listener): this {
let listeners = this.#listeners.get(event);
if (!listeners) {
listeners = new Set();
this.#listeners.set(event, listeners);
}
listeners.add(listener);
return this;
}
off(event: string, listener: Listener): this {
this.#listeners.get(event)?.delete(listener);
return this;
}
once(event: string, listener: Listener): this {
const wrapped: Listener = (...args) => {
this.off(event, wrapped);
listener(...args);
};
return this.on(event, wrapped);
}
emit(event: string, ...args: any[]): boolean {
const listeners = this.#listeners.get(event);
if (!listeners || listeners.size === 0) {
return false;
}
for (const listener of [...listeners]) {
listener(...args);
}
return true;
}
}

View file

@ -0,0 +1,526 @@
import {
CodexAppServerClient,
type CodexAppServerClientOptions,
} from "./client.ts";
import type { v2 } from "./generated/index.ts";
import type { JsonRpcNotification } from "./rpc.ts";
type JsonValue = NonNullable<v2.TurnStartParams["outputSchema"]>;
type ThreadConfig = NonNullable<v2.ThreadStartParams["config"]>;
type ThreadResumeConfig = NonNullable<v2.ThreadResumeParams["config"]>;
export type CodexFlowAppServerClient = {
connect(): Promise<void>;
close(): void;
startThread(params: v2.ThreadStartParams): Promise<v2.ThreadStartResponse>;
resumeThread(params: v2.ThreadResumeParams): Promise<v2.ThreadResumeResponse>;
readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse>;
startTurn(params: v2.TurnStartParams): Promise<v2.TurnStartResponse>;
on?(event: string, listener: (...args: any[]) => void): unknown;
off?(event: string, listener: (...args: any[]) => void): unknown;
};
export type CodexFlowClientOptions = {
client?: CodexFlowAppServerClient;
appServerUrl?: string;
requestTimeoutMs?: number;
clientName?: string;
clientTitle?: string;
clientVersion?: string;
closeInjectedClient?: boolean;
};
export type CodexFlowInputItem =
| v2.UserInput
| {
type: "text";
text: string;
text_elements?: v2.TextElement[];
};
export type CodexFlowInput =
| string
| CodexFlowInputItem
| CodexFlowInputItem[];
export type CodexFlowThreadOptions = Partial<
Omit<v2.ThreadStartParams, "experimentalRawEvents" | "persistExtendedHistory">
> & {
experimentalRawEvents?: boolean;
persistExtendedHistory?: boolean;
};
export type CodexFlowResumeOptions = Partial<
Omit<v2.ThreadResumeParams, "threadId" | "persistExtendedHistory">
> & {
persistExtendedHistory?: boolean;
};
export type CodexFlowTurnOptions = Partial<
Omit<v2.TurnStartParams, "threadId" | "input">
>;
export type CodexFlowWaitOptions = {
timeoutMs?: number;
pollIntervalMs?: number;
signal?: AbortSignal;
throwOnFailure?: boolean;
};
export type StartCodexFlowParams = {
threadId?: string;
prompt?: string;
input?: CodexFlowInput;
cwd?: string | null;
model?: string | null;
modelProvider?: string | null;
serviceTier?: string | null;
approvalPolicy?: v2.AskForApproval | null;
approvalsReviewer?: v2.ApprovalsReviewer | null;
sandbox?: v2.SandboxMode | null;
permissions?: v2.PermissionProfileSelectionParams | null;
config?: ThreadConfig | ThreadResumeConfig | null;
baseInstructions?: string | null;
developerInstructions?: string | null;
personality?: v2.ThreadStartParams["personality"];
outputSchema?: JsonValue | null;
thread?: CodexFlowThreadOptions;
resume?: CodexFlowResumeOptions | false;
turn?: CodexFlowTurnOptions;
wait?: boolean | CodexFlowWaitOptions;
};
export type CodexFlowStartResult = {
thread: v2.Thread;
turn: v2.Turn;
threadId: string;
turnId: string;
completedTurn?: v2.Turn;
};
export type WaitForTurnParams = {
threadId: string;
turnId: string;
timeoutMs?: number;
pollIntervalMs?: number;
signal?: AbortSignal;
throwOnFailure?: boolean;
};
const DEFAULT_WAIT_TIMEOUT_MS = 120_000;
const DEFAULT_POLL_INTERVAL_MS = 1_000;
export class CodexFlowTimeoutError extends Error {
readonly threadId: string;
readonly turnId: string;
readonly timeoutMs: number;
constructor(params: { threadId: string; turnId: string; timeoutMs: number }) {
super(
`Timed out waiting for Codex turn ${params.turnId} on thread ${params.threadId}`,
);
this.name = "CodexFlowTimeoutError";
this.threadId = params.threadId;
this.turnId = params.turnId;
this.timeoutMs = params.timeoutMs;
}
}
export class CodexFlowTurnFailedError extends Error {
readonly threadId: string;
readonly turn: v2.Turn;
constructor(threadId: string, turn: v2.Turn) {
super(turn.error?.message ?? `Codex turn ${turn.id} failed`);
this.name = "CodexFlowTurnFailedError";
this.threadId = threadId;
this.turn = turn;
}
}
export class CodexFlowClient {
readonly client: CodexFlowAppServerClient;
#connected = false;
#closeClient: boolean;
constructor(options: CodexFlowClientOptions = {}) {
this.client =
options.client ??
new CodexAppServerClient({
...clientIdentityOptions(options),
webSocketTransportOptions: options.appServerUrl
? {
url: options.appServerUrl,
requestTimeoutMs: options.requestTimeoutMs,
}
: undefined,
transportOptions: options.requestTimeoutMs
? { requestTimeoutMs: options.requestTimeoutMs }
: undefined,
});
this.#closeClient = options.client
? options.closeInjectedClient === true
: true;
}
async connect(): Promise<void> {
if (this.#connected) {
return;
}
await this.client.connect();
this.#connected = true;
}
close(): void {
this.#connected = false;
if (this.#closeClient) {
this.client.close();
}
}
async startFlow(params: StartCodexFlowParams): Promise<CodexFlowStartResult> {
await this.connect();
const input = [
...toCodexUserInput(params.prompt),
...toCodexUserInput(params.input),
];
if (input.length === 0) {
throw new Error("Codex flow input is required");
}
const thread = await this.#openThread(params);
const turnResponse = await this.client.startTurn(
turnStartParams(thread.id, input, params),
);
const result: CodexFlowStartResult = {
thread,
turn: turnResponse.turn,
threadId: thread.id,
turnId: turnResponse.turn.id,
};
const waitOptions = normalizeWait(params.wait);
if (waitOptions) {
result.completedTurn = await this.waitForTurn({
threadId: thread.id,
turnId: turnResponse.turn.id,
...waitOptions,
});
}
return result;
}
async waitForTurn(params: WaitForTurnParams): Promise<v2.Turn> {
await this.connect();
const timeoutMs = params.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
const pollIntervalMs = params.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const signal = params.signal;
const throwOnFailure = params.throwOnFailure === true;
return new Promise<v2.Turn>((resolve, reject) => {
let settled = false;
let polling = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let interval: ReturnType<typeof setInterval> | undefined;
const settle = (turn: v2.Turn): void => {
if (settled) {
return;
}
settled = true;
cleanup();
try {
resolve(maybeThrowForFailedTurn(params.threadId, turn, throwOnFailure));
} catch (error) {
reject(error);
}
};
const fail = (error: Error): void => {
if (settled) {
return;
}
settled = true;
cleanup();
reject(error);
};
const onNotification = (message: JsonRpcNotification): void => {
const turn = completedTurnFromNotification(
message,
params.threadId,
params.turnId,
);
if (turn) {
settle(turn);
}
};
const onClose = (): void => {
fail(new Error("Codex app-server connection closed while waiting for a turn"));
};
const onAbort = (): void => {
fail(new Error("Codex flow wait aborted"));
};
const poll = (): void => {
if (polling || settled) {
return;
}
polling = true;
void this.#findTurn(params.threadId, params.turnId)
.then((turn) => {
if (turn && isTerminalTurn(turn)) {
settle(turn);
}
})
.catch((error: unknown) => fail(asError(error)))
.finally(() => {
polling = false;
});
};
const cleanup = (): void => {
if (timeout) clearTimeout(timeout);
if (interval) clearInterval(interval);
this.client.off?.("notification", onNotification);
this.client.off?.("close", onClose);
signal?.removeEventListener("abort", onAbort);
};
this.client.on?.("notification", onNotification);
this.client.on?.("close", onClose);
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) {
onAbort();
return;
}
timeout = setTimeout(
() =>
fail(
new CodexFlowTimeoutError({
threadId: params.threadId,
turnId: params.turnId,
timeoutMs,
}),
),
timeoutMs,
);
if (pollIntervalMs > 0) {
interval = setInterval(poll, pollIntervalMs);
}
poll();
});
}
async readThread(
threadId: string,
options: { includeTurns?: boolean } = {},
): Promise<v2.Thread> {
await this.connect();
const response = await this.client.readThread({
threadId,
includeTurns: options.includeTurns === true,
});
return response.thread;
}
async #openThread(params: StartCodexFlowParams): Promise<v2.Thread> {
if (params.threadId) {
if (params.resume === false) {
return this.readThread(params.threadId, { includeTurns: false });
}
const response = await this.client.resumeThread(
threadResumeParams(params.threadId, params),
);
return response.thread;
}
const response = await this.client.startThread(threadStartParams(params));
return response.thread;
}
async #findTurn(threadId: string, turnId: string): Promise<v2.Turn | undefined> {
const thread = await this.readThread(threadId, { includeTurns: true });
return thread.turns.find((turn) => turn.id === turnId);
}
}
export function createCodexFlowClient(
options: CodexFlowClientOptions = {},
): CodexFlowClient {
return new CodexFlowClient(options);
}
export function toCodexUserInput(
input: string | CodexFlowInput | undefined,
): v2.UserInput[] {
if (!input) {
return [];
}
if (typeof input === "string") {
return [{ type: "text", text: input, text_elements: [] }];
}
const items = Array.isArray(input) ? input : [input];
return items.map((item) => {
if (item.type === "text") {
return {
type: "text",
text: item.text,
text_elements: item.text_elements ?? [],
};
}
return item;
}) as v2.UserInput[];
}
export function isTerminalTurn(turn: v2.Turn): boolean {
return turn.status !== "inProgress";
}
function clientIdentityOptions(
options: CodexFlowClientOptions,
): Pick<
CodexAppServerClientOptions,
"clientName" | "clientTitle" | "clientVersion"
> {
return compactUndefined({
clientName: options.clientName ?? "peezy-tech-codex-flows",
clientTitle: options.clientTitle ?? "Codex Flows SDK",
clientVersion: options.clientVersion ?? "0.1.0",
});
}
function threadStartParams(params: StartCodexFlowParams): v2.ThreadStartParams {
const thread = params.thread ?? {};
return compactUndefined({
...thread,
model: params.model ?? thread.model,
modelProvider: params.modelProvider ?? thread.modelProvider,
serviceTier: params.serviceTier ?? thread.serviceTier,
cwd: params.cwd ?? thread.cwd,
approvalPolicy: params.approvalPolicy ?? thread.approvalPolicy,
approvalsReviewer: params.approvalsReviewer ?? thread.approvalsReviewer,
sandbox: params.sandbox ?? thread.sandbox,
permissions: params.permissions ?? thread.permissions,
config: params.config ?? thread.config,
baseInstructions: params.baseInstructions ?? thread.baseInstructions,
developerInstructions:
params.developerInstructions ?? thread.developerInstructions,
personality: params.personality ?? thread.personality,
experimentalRawEvents: thread.experimentalRawEvents ?? false,
persistExtendedHistory: thread.persistExtendedHistory ?? false,
});
}
function threadResumeParams(
threadId: string,
params: StartCodexFlowParams,
): v2.ThreadResumeParams {
const resume =
params.resume === undefined || params.resume === false ? {} : params.resume;
return compactUndefined({
...resume,
threadId,
model: params.model ?? resume.model,
modelProvider: params.modelProvider ?? resume.modelProvider,
serviceTier: params.serviceTier ?? resume.serviceTier,
cwd: params.cwd ?? resume.cwd,
approvalPolicy: params.approvalPolicy ?? resume.approvalPolicy,
approvalsReviewer: params.approvalsReviewer ?? resume.approvalsReviewer,
sandbox: params.sandbox ?? resume.sandbox,
permissions: params.permissions ?? resume.permissions,
config: params.config ?? resume.config,
baseInstructions: params.baseInstructions ?? resume.baseInstructions,
developerInstructions:
params.developerInstructions ?? resume.developerInstructions,
personality: params.personality ?? resume.personality,
excludeTurns: resume.excludeTurns ?? true,
persistExtendedHistory: resume.persistExtendedHistory ?? false,
});
}
function turnStartParams(
threadId: string,
input: v2.UserInput[],
params: StartCodexFlowParams,
): v2.TurnStartParams {
const turn = params.turn ?? {};
return compactUndefined({
...turn,
threadId,
input,
cwd: params.cwd ?? turn.cwd,
approvalPolicy: params.approvalPolicy ?? turn.approvalPolicy,
approvalsReviewer: params.approvalsReviewer ?? turn.approvalsReviewer,
permissions: params.permissions ?? turn.permissions,
model: params.model ?? turn.model,
serviceTier: params.serviceTier ?? turn.serviceTier,
personality: params.personality ?? turn.personality,
outputSchema: params.outputSchema ?? turn.outputSchema,
});
}
function normalizeWait(
wait: StartCodexFlowParams["wait"],
): CodexFlowWaitOptions | undefined {
if (wait === true) {
return {};
}
if (!wait) {
return undefined;
}
return wait;
}
function completedTurnFromNotification(
message: JsonRpcNotification,
threadId: string,
turnId: string,
): v2.Turn | undefined {
if (message.method !== "turn/completed" || !isRecord(message.params)) {
return undefined;
}
if (message.params.threadId !== threadId || !isRecord(message.params.turn)) {
return undefined;
}
const turn = message.params.turn as Partial<v2.Turn>;
return turn.id === turnId && typeof turn.status === "string"
? (turn as v2.Turn)
: undefined;
}
function maybeThrowForFailedTurn(
threadId: string,
turn: v2.Turn,
throwOnFailure: boolean,
): v2.Turn {
if (throwOnFailure && turn.status === "failed") {
throw new CodexFlowTurnFailedError(threadId, turn);
}
return turn;
}
function compactUndefined<T extends Record<string, unknown>>(value: T): T {
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (entry !== undefined) {
result[key] = entry;
}
}
return result as T;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

View file

@ -0,0 +1,14 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A path that is guaranteed to be absolute and normalized (though it is not
* guaranteed to be canonicalized or exist on the filesystem).
*
* IMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set
* using [AbsolutePathBufGuard::new]. If no base path is set, the
* deserialization will fail unless the path being deserialized is already
* absolute.
*/
export type AbsolutePathBuf = string;

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AgentPath = string;

View file

@ -0,0 +1,21 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FileChange } from "./FileChange";
import type { ThreadId } from "./ThreadId";
export type ApplyPatchApprovalParams = { conversationId: ThreadId,
/**
* Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent]
* and [codex_protocol::protocol::PatchApplyEndEvent].
*/
callId: string, fileChanges: { [key in string]?: FileChange },
/**
* Optional explanatory reason (e.g. request for extra write access).
*/
reason: string | null,
/**
* When set, the agent is asking the user to allow writes under this root
* for the remainder of the session (unclear if this is honored today).
*/
grantRoot: string | null, };

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReviewDecision } from "./ReviewDecision";
export type ApplyPatchApprovalResponse = { decision: ReviewDecision, };

View file

@ -0,0 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Authentication mode for OpenAI-backed providers.
*/
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity";

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ClientInfo = { name: string, title: string | null, version: string, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ClientNotification = { "method": "initialized" };

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,10 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ModeKind } from "./ModeKind";
import type { Settings } from "./Settings";
/**
* Collaboration mode for a Codex session.
*/
export type CollaborationMode = { mode: ModeKind, settings: Settings, };

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ImageDetail } from "./ImageDetail";
export type ContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "output_text", text: string, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ConversationGitInfo = { sha: string | null, branch: string | null, origin_url: string | null, };

View file

@ -0,0 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConversationGitInfo } from "./ConversationGitInfo";
import type { SessionSource } from "./SessionSource";
import type { ThreadId } from "./ThreadId";
export type ConversationSummary = { conversationId: ThreadId, path: string, preview: string, timestamp: string | null, updatedAt: string | null, modelProvider: string, cwd: string, cliVersion: string, source: SessionSource, gitInfo: ConversationGitInfo | null, };

View file

@ -0,0 +1,16 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ParsedCommand } from "./ParsedCommand";
import type { ThreadId } from "./ThreadId";
export type ExecCommandApprovalParams = { conversationId: ThreadId,
/**
* Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent]
* and [codex_protocol::protocol::ExecCommandEndEvent].
*/
callId: string,
/**
* Identifier for this specific approval callback.
*/
approvalId: string | null, command: Array<string>, cwd: string, reason: string | null, parsedCmd: Array<ParsedCommand>, };

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReviewDecision } from "./ReviewDecision";
export type ExecCommandApprovalResponse = { decision: ReviewDecision, };

View file

@ -0,0 +1,12 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Proposed execpolicy change to allow commands starting with this prefix.
*
* The `command` tokens form the prefix that would be added as an execpolicy
* `prefix_rule(..., decision="allow")`, letting the agent bypass approval for
* commands that start with this token sequence.
*/
export type ExecPolicyAmendment = Array<string>;

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FileChange = { "type": "add", content: string, } | { "type": "delete", content: string, } | { "type": "update", unified_diff: string, move_path: string | null, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ForcedLoginMethod = "chatgpt" | "api";

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FunctionCallOutputContentItem } from "./FunctionCallOutputContentItem";
export type FunctionCallOutputBody = string | Array<FunctionCallOutputContentItem>;

View file

@ -0,0 +1,10 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ImageDetail } from "./ImageDetail";
/**
* Responses API compatible content items that can be returned by a tool call.
* This is a subset of ContentItem with the types we support as function call outputs.
*/
export type FunctionCallOutputContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FuzzyFileSearchMatchType = "file" | "directory";

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FuzzyFileSearchParams = { query: string, roots: Array<string>, cancellationToken: string | null, };

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FuzzyFileSearchResult } from "./FuzzyFileSearchResult";
export type FuzzyFileSearchResponse = { files: Array<FuzzyFileSearchResult>, };

View file

@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FuzzyFileSearchMatchType } from "./FuzzyFileSearchMatchType";
/**
* Superset of [`codex_file_search::FileMatch`]
*/
export type FuzzyFileSearchResult = { root: string, path: string, match_type: FuzzyFileSearchMatchType, file_name: string, score: number, indices: Array<number> | null, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FuzzyFileSearchSessionCompletedNotification = { sessionId: string, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FuzzyFileSearchSessionStartParams = { sessionId: string, roots: Array<string>, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FuzzyFileSearchSessionStartResponse = Record<string, never>;

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FuzzyFileSearchSessionStopParams = { sessionId: string, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FuzzyFileSearchSessionStopResponse = Record<string, never>;

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FuzzyFileSearchSessionUpdateParams = { sessionId: string, query: string, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FuzzyFileSearchSessionUpdateResponse = Record<string, never>;

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FuzzyFileSearchResult } from "./FuzzyFileSearchResult";
export type FuzzyFileSearchSessionUpdatedNotification = { sessionId: string, query: string, files: Array<FuzzyFileSearchResult>, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type GetAuthStatusParams = { includeToken: boolean | null, refreshToken: boolean | null, };

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AuthMode } from "./AuthMode";
export type GetAuthStatusResponse = { authMethod: AuthMode | null, authToken: string | null, requiresOpenaiAuth: boolean | null, };

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadId } from "./ThreadId";
export type GetConversationSummaryParams = { rolloutPath: string, } | { conversationId: ThreadId, };

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConversationSummary } from "./ConversationSummary";
export type GetConversationSummaryResponse = { summary: ConversationSummary, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type GitDiffToRemoteParams = { cwd: string, };

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { GitSha } from "./GitSha";
export type GitDiffToRemoteResponse = { sha: GitSha, diff: string, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type GitSha = string;

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ImageDetail = "auto" | "low" | "high" | "original";

View file

@ -0,0 +1,17 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Client-declared capabilities negotiated during initialize.
*/
export type InitializeCapabilities = {
/**
* Opt into receiving experimental API methods and fields.
*/
experimentalApi: boolean,
/**
* Exact notification method names that should be suppressed for this
* connection (for example `thread/started`).
*/
optOutNotificationMethods?: Array<string> | null, };

View file

@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ClientInfo } from "./ClientInfo";
import type { InitializeCapabilities } from "./InitializeCapabilities";
export type InitializeParams = { clientInfo: ClientInfo, capabilities: InitializeCapabilities | null, };

View file

@ -0,0 +1,20 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "./AbsolutePathBuf";
export type InitializeResponse = { userAgent: string,
/**
* Absolute path to the server's $CODEX_HOME directory.
*/
codexHome: AbsolutePathBuf,
/**
* Platform family for the running app-server target, for example
* `"unix"` or `"windows"`.
*/
platformFamily: string,
/**
* Operating system for the running app-server target, for example
* `"macos"`, `"linux"`, or `"windows"`.
*/
platformOs: string, };

View file

@ -0,0 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Canonical user-input modality tags advertised by a model.
*/
export type InputModality = "text" | "image";

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InternalSessionSource = "memory_consolidation";

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LocalShellExecAction } from "./LocalShellExecAction";
export type LocalShellAction = { "type": "exec" } & LocalShellExecAction;

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type LocalShellExecAction = { command: Array<string>, timeout_ms: bigint | null, working_directory: string | null, env: { [key in string]?: string } | null, user: string | null, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type LocalShellStatus = "completed" | "in_progress" | "incomplete";

Some files were not shown because too many files have changed in this diff Show more