Initial codex-flows monorepo
This commit is contained in:
commit
3c446b11a4
642 changed files with 19676 additions and 0 deletions
43
.github/workflows/publish-codex-flows.yml
vendored
Normal file
43
.github/workflows/publish-codex-flows.yml
vendored
Normal 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
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
*.log
|
||||
25
CODE_OF_CONDUCT.md
Normal file
25
CODE_OF_CONDUCT.md
Normal 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
18
CONTRIBUTING.md
Normal 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
176
LICENSE
Normal 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
123
README.md
Normal 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
8
SECURITY.md
Normal 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
24
apps/cli/package.json
Normal 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
113
apps/cli/src/actions.ts
Normal 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
115
apps/cli/src/args.ts
Normal 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
123
apps/cli/src/index.ts
Normal 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();
|
||||
55
apps/cli/test/args.test.ts
Normal file
55
apps/cli/test/args.test.ts
Normal 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
24
apps/cli/tsconfig.json
Normal 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"]
|
||||
}
|
||||
27
apps/discord-bridge/package.json
Normal file
27
apps/discord-bridge/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
1051
apps/discord-bridge/src/bridge.ts
Normal file
1051
apps/discord-bridge/src/bridge.ts
Normal file
File diff suppressed because it is too large
Load diff
368
apps/discord-bridge/src/config.ts
Normal file
368
apps/discord-bridge/src/config.ts
Normal 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);
|
||||
}
|
||||
99
apps/discord-bridge/src/console-output.ts
Normal file
99
apps/discord-bridge/src/console-output.ts
Normal 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;
|
||||
}
|
||||
380
apps/discord-bridge/src/discord-transport.ts
Normal file
380
apps/discord-bridge/src/discord-transport.ts
Normal 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);
|
||||
}
|
||||
96
apps/discord-bridge/src/index.ts
Normal file
96
apps/discord-bridge/src/index.ts
Normal 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();
|
||||
71
apps/discord-bridge/src/logger.ts
Normal file
71
apps/discord-bridge/src/logger.ts
Normal 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];
|
||||
}
|
||||
225
apps/discord-bridge/src/pretty-log.ts
Normal file
225
apps/discord-bridge/src/pretty-log.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
2208
apps/discord-bridge/src/runner.ts
Normal file
2208
apps/discord-bridge/src/runner.ts
Normal file
File diff suppressed because it is too large
Load diff
222
apps/discord-bridge/src/state.ts
Normal file
222
apps/discord-bridge/src/state.ts
Normal 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);
|
||||
}
|
||||
177
apps/discord-bridge/src/types.ts
Normal file
177
apps/discord-bridge/src/types.ts
Normal 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>;
|
||||
};
|
||||
2340
apps/discord-bridge/test/bridge.test.ts
Normal file
2340
apps/discord-bridge/test/bridge.test.ts
Normal file
File diff suppressed because it is too large
Load diff
223
apps/discord-bridge/test/config.test.ts
Normal file
223
apps/discord-bridge/test/config.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
79
apps/discord-bridge/test/console-output.test.ts
Normal file
79
apps/discord-bridge/test/console-output.test.ts
Normal 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("");
|
||||
},
|
||||
};
|
||||
}
|
||||
98
apps/discord-bridge/test/logger.test.ts
Normal file
98
apps/discord-bridge/test/logger.test.ts
Normal 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("");
|
||||
},
|
||||
};
|
||||
}
|
||||
103
apps/discord-bridge/test/state.test.ts
Normal file
103
apps/discord-bridge/test/state.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
25
apps/discord-bridge/tsconfig.json
Normal file
25
apps/discord-bridge/tsconfig.json
Normal 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
23
apps/web/components.json
Normal 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
14
apps/web/index.html
Normal 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
29
apps/web/package.json
Normal 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
886
apps/web/src/App.tsx
Normal 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(" ");
|
||||
}
|
||||
10
apps/web/src/components/theme-provider.tsx
Normal file
10
apps/web/src/components/theme-provider.tsx
Normal 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
12
apps/web/src/main.tsx
Normal 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
1
apps/web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
31
apps/web/tsconfig.app.json
Normal file
31
apps/web/tsconfig.app.json
Normal 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
16
apps/web/tsconfig.json
Normal 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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
22
apps/web/tsconfig.node.json
Normal file
22
apps/web/tsconfig.node.json
Normal 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
36
apps/web/vite.config.ts
Normal 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");
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[install]
|
||||
auto = "disable"
|
||||
32
mprocs.yaml
Normal file
32
mprocs.yaml
Normal 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
47
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
124
packages/codex-client/README.md
Normal file
124
packages/codex-client/README.md
Normal 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.
|
||||
71
packages/codex-client/package.json
Normal file
71
packages/codex-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
67
packages/codex-client/scripts/pack-dry-run.ts
Normal file
67
packages/codex-client/scripts/pack-dry-run.ts
Normal 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`;
|
||||
}
|
||||
18
packages/codex-client/scripts/smoke-exports.ts
Normal file
18
packages/codex-client/scripts/smoke-exports.ts
Normal 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");
|
||||
133
packages/codex-client/src/app-server/browser-client.ts
Normal file
133
packages/codex-client/src/app-server/browser-client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
244
packages/codex-client/src/app-server/client.ts
Normal file
244
packages/codex-client/src/app-server/client.ts
Normal 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);
|
||||
}
|
||||
39
packages/codex-client/src/app-server/events.ts
Normal file
39
packages/codex-client/src/app-server/events.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
526
packages/codex-client/src/app-server/flows.ts
Normal file
526
packages/codex-client/src/app-server/flows.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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";
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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" };
|
||||
105
packages/codex-client/src/app-server/generated/ClientRequest.ts
Normal file
105
packages/codex-client/src/app-server/generated/ClientRequest.ts
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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>, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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";
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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>, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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>, };
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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>, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
5
packages/codex-client/src/app-server/generated/GitSha.ts
Normal file
5
packages/codex-client/src/app-server/generated/GitSha.ts
Normal 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;
|
||||
|
|
@ -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";
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue