Extract Discord gateways from main workspace

This commit is contained in:
matamune 2026-05-27 19:57:56 +00:00
parent f350608a93
commit 6cb76d9f4b
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
58 changed files with 17 additions and 20628 deletions

View file

@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
confirm_package:
description: "Type a package name to publish, or publish-codex-flows-packages for all"
description: "Type @peezy.tech/codex-flows or publish-codex-flows-packages"
required: true
type: string
@ -14,7 +14,7 @@ permissions:
jobs:
publish:
if: inputs.confirm_package == 'publish-codex-flows-packages' || inputs.confirm_package == '@peezy.tech/codex-flows' || inputs.confirm_package == '@peezy.tech/codex-discord-bridge' || inputs.confirm_package == '@peezy.tech/codex-workspace-voice-gateway'
if: inputs.confirm_package == 'publish-codex-flows-packages' || inputs.confirm_package == '@peezy.tech/codex-flows'
runs-on: ubuntu-latest
environment: npm-publish
steps:
@ -59,33 +59,3 @@ jobs:
tarball=$(find "$tmpdir" -maxdepth 1 -name '*.tgz' -print -quit)
npm publish "$tarball" --access public --provenance
fi
- name: Publish @peezy.tech/codex-discord-bridge
if: inputs.confirm_package == 'publish-codex-flows-packages' || inputs.confirm_package == '@peezy.tech/codex-discord-bridge'
working-directory: apps/discord-bridge
run: |
version=$(node -p "require('./package.json').version")
if npm view "@peezy.tech/codex-discord-bridge@$version" version --json >/dev/null 2>&1; then
echo "@peezy.tech/codex-discord-bridge@$version is already published"
else
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
pnpm pack --pack-destination "$tmpdir"
tarball=$(find "$tmpdir" -maxdepth 1 -name '*.tgz' -print -quit)
npm publish "$tarball" --access public --provenance
fi
- name: Publish @peezy.tech/codex-workspace-voice-gateway
if: inputs.confirm_package == 'publish-codex-flows-packages' || inputs.confirm_package == '@peezy.tech/codex-workspace-voice-gateway'
working-directory: apps/workspace-voice-gateway
run: |
version=$(node -p "require('./package.json').version")
if npm view "@peezy.tech/codex-workspace-voice-gateway@$version" version --json >/dev/null 2>&1; then
echo "@peezy.tech/codex-workspace-voice-gateway@$version is already published"
else
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
pnpm pack --pack-destination "$tmpdir"
tarball=$(find "$tmpdir" -maxdepth 1 -name '*.tgz' -print -quit)
npm publish "$tarball" --access public --provenance
fi

View file

@ -28,11 +28,13 @@ or docs surface that solves the problem:
- `packages/codex-client` owns the public `@peezy.tech/codex-flows` package,
app-server clients, generated protocol types, workspace helpers, and bundled
bins.
- `apps/discord-bridge` and `apps/workspace-voice-gateway` are gateway packages
that depend on `@peezy.tech/codex-flows`.
- `apps/workspace-backend`, `apps/web`, and `apps/cli` are
workspace-local apps that are also bundled into the core package where
appropriate.
- Discord gateway integrations are extracted from the main monorepo lifecycle.
Keep shared runtime surfaces in `@peezy.tech/codex-flows`; channel-specific
packages should live in their own repository and depend on the published core
package.
- `docs/pages` is the canonical user documentation.
Generated app-server protocol files live under

View file

@ -83,27 +83,22 @@ Canonical user-facing package:
- `@peezy.tech/codex-flows`
Gateway packages:
- `@peezy.tech/codex-discord-bridge`
- `@peezy.tech/codex-workspace-voice-gateway`
The GitHub publish workflow checks whether each package version already exists
on npm. It publishes new versions and skips versions that are already present.
Published packages are packed with `pnpm pack` and then handed to `npm publish`
so workspace and catalog dependency specifiers are converted before the npm
registry sees the package while GitHub provenance still comes from npm.
on npm. It publishes a new `@peezy.tech/codex-flows` version and skips versions
that are already present. Published packages are packed with `pnpm pack` and
then handed to `npm publish` so workspace and catalog dependency specifiers are
converted before the npm registry sees the package while GitHub provenance still
comes from npm.
Version numbers intentionally track the upstream Codex release line rather than
strict semantic-versioning meaning. For example, if the current Codex-aligned
line is `0.132.x`, a breaking codex-flows stack release should usually advance
to `0.132.1` rather than `0.133.0`. Keep public package versions aligned across
the stack.
to `0.132.1` rather than `0.133.0`.
New public core runtime surfaces should be exported through
`@peezy.tech/codex-flows` first, including reusable protocol helpers and
runnable local backend bins. Product- or channel-specific gateways, such as
Discord text or voice packages, should publish separately and depend on
`@peezy.tech/codex-flows`.
`@peezy.tech/codex-flows` from their own repositories.
Before publishing:
@ -129,6 +124,4 @@ To publish through GitHub trusted publishing:
```bash
npm dist-tag ls @peezy.tech/codex-flows
npm dist-tag ls @peezy.tech/codex-discord-bridge
npm dist-tag ls @peezy.tech/codex-workspace-voice-gateway
```

View file

@ -1,278 +0,0 @@
# @peezy.tech/codex-discord-bridge
Long-lived Discord gateway for connecting Discord to Codex app-server threads
through `@peezy.tech/codex-flows`.
```bash
pnpm add @peezy.tech/codex-discord-bridge
```
## Workspace Mode
Workspace mode is opt-in. It keeps one Discord home surface as the primary UX, or
several guild-scoped surfaces when multi-guild routing is configured, and one
main Codex thread as the operator memory for the workspace. Legacy
thread-per-task behavior remains available outside the configured workspace
channels.
Set these environment values before starting the bridge:
```bash
CODEX_DISCORD_HOME_CHANNEL_ID=1502107617512919220
CODEX_DISCORD_MAIN_THREAD_ID=019e2509-ddbb-7380-b97b-41575092d86b
CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID=1502107617512919221
CODEX_DISCORD_TASK_THREADS_CHANNEL_ID=1502107617512919222
CODEX_DISCORD_ALLOWED_CHANNEL_IDS=1502107617512919220
CODEX_DISCORD_DIR=/home/peezy
CODEX_DISCORD_HOOK_SPOOL_DIR=/home/peezy/.codex/discord-bridge/stop-hooks
```
Single-surface `.env` configuration remains supported and acts as the default
surface. For multiple guilds, define a workspace-owned surface in that
workspace's `.codex/workspace.toml`, and keep one bridge process. The bridge
checks the resolved `CODEX_DISCORD_DIR` / `--dir` root and each discoverable
top-level workspace under it:
```toml
# /home/peezy/crypto-workspace/.codex/workspace.toml
[[discord.workspace.surfaces]]
key = "crypto"
home_channel_id = "1503107617512919220"
workspace_forum_channel_id = "1503107617512919221"
task_threads_channel_id = "1503107617512919222"
```
Each surface owns its home channel, workspace forum, and task-thread channel.
The workspace file does not list workspace paths; the file's containing
workspace is the route. Workspaces without a Discord surface entry use the
default `.env` surface. If multiple workspaces name the same surface key with
the same channel ids, they are merged into one guild surface. Surface keys and
channel ids must be unique, and each workspace file may contain at most one
`[[discord.workspace.surfaces]]` entry.
`CODEX_DISCORD_MAIN_THREAD_ID` is optional. If omitted, the bridge creates a new
main operator thread, attaches the privileged workspace tools to it, and stores it
in the bridge state file. Existing configured main threads are resumed as-is;
recreate the main operator thread if you need to attach workspace tools to a
thread that predates workspace mode.
In each configured home channel:
- normal messages are sent to the main operator thread
- bot mentions are treated as workspace messages and do not create Discord task
threads
- `/status` replies directly with workspace state instead of starting a Codex turn
- `/status` also lists active Codex threads, linking any opened Discord thread
on the same surface and offering private buttons to open active threads that
are not yet in Discord
- `/goals` is available from workspace forum posts and opens an ephemeral goal
management picker for that workspace
- `/goals` inside an opened Codex Discord thread manages that specific thread's
goal; use the slash options to set the objective/status/token budget or clear it
The prompt sent to the main thread uses `[discord-workspace]` framing so the model
knows it is operating as the workspace over the codex-flows backend, not as a
single task thread.
## Delegation Tools
Discord should not become a workspace registry. The main operator thread is the
place where routing decisions happen. Privileged `codex_workspace` dynamic tools
are attached only to that main thread and expose:
- `list_delegations`
- `start_delegation`
- `resume_delegation`
- `send_delegation`
- `read_delegation`
- `set_delegation_policy`
- `flush_delegation_results`
- `list_delegation_groups`
Those tools can:
- list tracked delegated Codex sessions
- start a delegated Codex session in a requested cwd
- resume a delegated Codex session by thread id
- send a turn to a delegated session
- observe or summarize delegated session state
- group delegations for fan-out/fan-in coordination
- record completed delegation results into the main operator thread
Workspace state stores delegation records, including optional Discord detail
thread ids for noisy work. Delegated Codex sessions do not receive the privileged
workspace tools; only the main operator thread can manage delegation.
## Workbench Prototype
The workspace can optionally maintain a noisy Discord workbench beside each home
channel. Configure both channels on the default `.env` surface, or on a
workspace-owned `[[discord.workspace.surfaces]]` entry, to enable it:
```bash
CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID=1502107617512919221
CODEX_DISCORD_TASK_THREADS_CHANNEL_ID=1502107617512919222
```
The home channel remains the compact operator chat. Each surface's workspace
forum gets one post for each discoverable top-level folder under
`CODEX_DISCORD_DIR` that routes to that surface. `CODEX_DISCORD_DIR` is the
workspace's main workspace root. For the home-folder workspace, set
`CODEX_DISCORD_DIR=/home/peezy`; a delegated cwd such as
`/home/peezy/codex-fork-workspace/codex-flows` maps to the
`/home/peezy/codex-fork-workspace` workspace post. Hidden folders and
`node_modules` are skipped. Workspace posts are compact dashboards that only
show Codex threads already opened into Discord. Run `/threads` in a workspace
post to list all Codex threads for that workspace; the bridge replies with an
ephemeral numbered button picker visible only to the command sender. Choosing a
number opens or reuses one Discord task thread in that surface's task thread
channel, and messages in that Discord thread are routed directly to the opened
Codex thread.
When the workbench is enabled:
- `start_delegation` and `resume_delegation` create or reuse the workspace forum
post for the top-level workspace containing the delegation cwd
- bridge startup creates missing workspace forum posts for discoverable folders
under the main workspace root
- workspace dashboards list opened Discord task threads plus active hook-observed
workspace threads that have not been opened into Discord yet
- `/threads` lists known Codex threads from `thread/list` plus tracked
delegations that may not have appeared in the list yet
- choosing an item from the ephemeral `/threads` picker creates or reuses one
Discord task thread in that surface's task thread channel
- `/status` shows active Codex threads for the current surface and uses the same
surface-scoped ephemeral button flow to open active threads without Discord
task threads
- `/goals` in workspace forum posts lists recent workspace thread goals and lets
the command sender mark existing goals active, paused, or complete, clear
them, or open the thread into Discord
- `/goals` in an opened Discord task thread scopes CRUD to that Codex thread:
no options reads the current goal, `objective`/`status`/`token_budget` create
or update it, and `clear` removes it
- repeated delegations in the same cwd reuse the same workspace post and update
the workspace thread list
- Stop lifecycle events update the workspace dashboard and any already-opened
task thread on the routed surface
- the routed home channel receives only compact status/link messages for
completed delegations
- main-thread injection and wake behavior still follow the delegation return
mode
If both workbench channels are omitted, the workbench is disabled and the bridge
keeps the legacy home-channel result mirroring behavior. Setting only one
workbench channel is rejected as an invalid partial configuration.
Delegations support return modes:
- `wake_on_done`: inject and mirror the result, then wake the main operator when idle
- `wake_on_group`: inject and mirror each result, then wake once the whole group is terminal
- `record_only`: inject and mirror results without waking the main operator
- `manual`: keep results in workspace state until `flush_delegation_results`
- `detached`: do not loop results back to the main thread; useful for human-continued threads
Automatic result return uses `thread/inject_items` to append structured
delegation results to the main operator thread's model-visible history. Codex
hooks, not background thread polling, drive automatic result return and passive
observability. Plugin-bundled Codex hooks write durable lifecycle events into
the spool directory, and the workspace drains that spool on startup and while
running.
Starting a main-thread turn is a separate wake step, so long-running main goals
are not interrupted; wakes are queued until the main operator thread is idle.
For sessions that were not created through the workspace, the same hook stream
updates an observed-thread index used by `/threads`.
## Codex Hooks
Prefer installing the `codex-flows` Codex plugin. The plugin carries
`hooks/hooks.json` and a self-contained `hooks/hook-event.mjs` command, so Codex
can discover the lifecycle hooks through the native plugin hook surface instead
of a global `~/.codex/hooks.json` install.
Enable hooks for the Codex runtime that backs the workspace:
```toml
[features]
hooks = true
plugin_hooks = true
```
Then install the plugin from the repository marketplace and start a new thread
or restart the backing Codex runtime:
```bash
codex plugin marketplace add peezy-tech/codex-flows --ref main
codex plugin add codex-flows@codex-flows
```
The bridge and plugin hook default to `~/.codex/discord-bridge/stop-hooks` for
compatibility. Override the hook writer with `CODEX_FLOWS_HOOK_SPOOL_DIR` or
`CODEX_DISCORD_HOOK_SPOOL_DIR`, and pass the same directory to the bridge with
`--hook-spool-dir` when needed.
The plugin registers passive observability hooks equivalent to:
```json
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/hooks/hook-event.mjs\"",
"timeout": 10
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/hooks/hook-event.mjs\"",
"timeout": 10
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/hooks/hook-event.mjs\"",
"timeout": 10
}
]
}
]
}
}
```
The plugin also registers `PreToolUse`, `PermissionRequest`, and
`PostToolUse` with the same command. Those higher-volume events update local
observed-thread metadata such as status, current tool, or waiting reason; they
do not create Discord messages.
The old global hook installer remains available as a manual fallback when plugin
hooks are unavailable:
```bash
codex-discord-bridge hook install
codex-discord-bridge hook install --dlx
```
The hook is intentionally dumb: it does not read workspace state or call the
backend. It only writes idempotent lifecycle-event files and lets Codex
continue. The workspace treats known delegated `Stop` events according to their
return mode, uses main-operator `Stop` events to drain queued wakes, and records
unknown non-main sessions as observed threads. Observed threads are visible from
`/threads` for their workspace and can be opened into the task thread channel
on demand.
After changing hook configuration, restart the Codex runtime that backs the
workspace and trust the hook when Codex asks for review. `hooks/list` should show
the hook as `trusted`; untrusted hooks are discovered but do not run.

View file

@ -1,49 +0,0 @@
{
"name": "@peezy.tech/codex-discord-bridge",
"version": "0.132.6",
"description": "Long-lived Discord sidecar for bridging Discord threads to Codex app-server threads.",
"type": "module",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/peezy-tech/codex-flows.git",
"directory": "apps/discord-bridge"
},
"keywords": [
"codex",
"codex-app-server",
"discord",
"gateway"
],
"bin": {
"codex-discord-bridge": "dist/index.js"
},
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "vp run clean && tsx scripts/build-package.ts",
"check:types": "tsc --noEmit",
"clean": "rm -rf dist",
"pack:dry-run": "npm pack --dry-run --json",
"pretty-log": "tsx ./src/pretty-log.ts",
"prepack": "vp run build",
"release:check": "vp run test && vp run check:types && vp run build && vp run smoke:bin && vp run pack:dry-run",
"smoke:bin": "tsx scripts/smoke-bin.ts",
"start:debug:commentary": "tsx ./src/index.ts --local-app-server --debug --progress-mode commentary",
"test": "vp test run --root ../.. apps/discord-bridge/test"
},
"dependencies": {
"@peezy.tech/codex-flows": "workspace:*",
"discord.js": "^14.22.1",
"smol-toml": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

View file

@ -1,81 +0,0 @@
import { spawn } from "node:child_process";
import { chmod, mkdir, readdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "..");
const outDir = path.join(appRoot, "dist");
const packOutDir = path.join(outDir, ".pack");
const outfile = path.join(outDir, "index.js");
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
const proc = spawn("vp", [
"pack",
"src/index.ts",
"--platform=node",
"--format=esm",
"--target=node24",
"--out-dir",
packOutDir,
"--deps.never-bundle",
"@peezy.tech/codex-flows",
"--deps.never-bundle",
"@peezy.tech/codex-flows/*",
"--deps.never-bundle",
"discord.js",
], {
cwd: appRoot,
});
const [stdout, stderr, exitCode] = await Promise.all([
collectText(proc.stdout),
collectText(proc.stderr),
exitCodeFor(proc),
]);
if (exitCode !== 0) {
process.stderr.write(stderr);
process.stderr.write(stdout);
process.exit(exitCode);
}
await movePackOutput(packOutDir, outfile);
await rm(packOutDir, { recursive: true, force: true });
await chmod(outfile, 0o755);
process.stderr.write(`built ${path.relative(appRoot, outfile)}\n`);
async function movePackOutput(packDir: string, entryOutfile: string): Promise<void> {
await rename(path.join(packDir, "index.mjs"), entryOutfile);
for (const entry of await readdir(packDir, { withFileTypes: true })) {
if (!entry.isFile()) {
continue;
}
await rename(path.join(packDir, entry.name), path.join(outDir, entry.name));
}
}
function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
return new Promise((resolve, reject) => {
let output = "";
if (!stream) {
resolve(output);
return;
}
stream.setEncoding("utf8");
stream.on("data", (chunk: string) => {
output += chunk;
});
stream.once("error", reject);
stream.once("end", () => resolve(output));
});
}
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => resolve(code));
});
}

View file

@ -1,54 +0,0 @@
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "..");
const binPath = path.join(appRoot, "dist", "index.js");
await access(binPath);
const proc = spawn(process.execPath, [binPath, "--help"], {
cwd: appRoot,
});
const [stdout, stderr, exitCode] = await Promise.all([
collectText(proc.stdout),
collectText(proc.stderr),
exitCodeFor(proc),
]);
if (exitCode !== 0) {
process.stderr.write(stderr);
process.stderr.write(stdout);
process.exit(exitCode);
}
if (!stdout.includes("codex-discord-bridge")) {
throw new Error("codex-discord-bridge --help did not mention codex-discord-bridge");
}
console.log("bin smoke test passed");
function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
return new Promise((resolve, reject) => {
let output = "";
if (!stream) {
resolve(output);
return;
}
stream.setEncoding("utf8");
stream.on("data", (chunk: string) => {
output += chunk;
});
stream.once("error", reject);
stream.once("end", () => resolve(output));
});
}
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => resolve(code));
});
}

View file

@ -1,125 +0,0 @@
import type { DiscordBridgeLogger } from "./logger.ts";
import {
createDiscordBridgeLogger,
} from "./logger.ts";
import type {
DiscordBridgeConfig,
DiscordBridgeState,
DiscordBridgeTransport,
} from "./types.ts";
import type {
CodexWorkspaceBackend,
CodexWorkspacePresenter,
} from "./workspace-backend.ts";
import {
LocalCodexWorkspaceBackend,
type LocalCodexWorkspaceBackendOptions,
parseThreadStartIntent,
splitDiscordMessage,
} from "./local-workspace-backend.ts";
export { parseThreadStartIntent, splitDiscordMessage };
export { LocalCodexWorkspaceBackend };
export type { LocalCodexWorkspaceBackendOptions };
export type DiscordCodexBridgeLocalOptions =
Omit<LocalCodexWorkspaceBackendOptions, "presenter"> & {
transport: DiscordBridgeTransport;
backend?: undefined;
};
export type DiscordCodexBridgeBackendOptions = {
backend: CodexWorkspaceBackend;
transport: DiscordBridgeTransport;
logger?: DiscordBridgeLogger;
config?: Pick<DiscordBridgeConfig, "debug" | "logLevel">;
now?: () => Date;
};
export type DiscordCodexBridgeOptions =
| DiscordCodexBridgeLocalOptions
| DiscordCodexBridgeBackendOptions;
export class DiscordCodexBridge {
readonly transport: DiscordBridgeTransport;
readonly backend: CodexWorkspaceBackend;
#logger: DiscordBridgeLogger;
constructor(options: DiscordCodexBridgeOptions) {
this.transport = options.transport;
this.#logger = options.logger ??
createDiscordBridgeLogger({
debug: options.config?.debug,
logLevel: options.config?.logLevel,
now: options.now,
});
this.backend = options.backend ?? new LocalCodexWorkspaceBackend({
...options,
presenter: discordTransportPresenter(options.transport),
});
}
async start(): Promise<void> {
await this.backend.start();
await this.transport.start({
onInbound: (inbound) => {
void this.backend.handleInbound(inbound).catch((error) => {
this.#logger.debug("inbound.error", {
kind: inbound.kind,
channelId: inbound.channelId,
error: errorMessage(error),
});
this.#logger.error("inbound.failed", {
kind: inbound.kind,
channelId: inbound.channelId,
error: errorMessage(error),
});
});
},
});
await this.backend.startTransportDependentWork?.();
await this.transport.registerCommands(this.backend.commandRegistration());
await this.backend.startBackgroundWork?.();
}
async stop(): Promise<void> {
try {
await this.backend.stop();
} finally {
await this.transport.stop();
}
}
stateForTest(): DiscordBridgeState {
if (!this.backend.stateForTest) {
throw new Error("Workspace backend does not expose test state.");
}
return this.backend.stateForTest();
}
async flushSummariesForTest(): Promise<void> {
await this.backend.flushSummariesForTest?.();
}
}
function discordTransportPresenter(
transport: DiscordBridgeTransport,
): CodexWorkspacePresenter {
return {
createWorkspacePost: transport.createForumPost?.bind(transport),
createThread: transport.createThread.bind(transport),
sendMessage: transport.sendMessage.bind(transport),
updateMessage: transport.updateMessage?.bind(transport),
deleteMessage: transport.deleteMessage.bind(transport),
deleteWebhookMessages: transport.deleteWebhookMessages?.bind(transport),
deleteThread: transport.deleteThread?.bind(transport),
addThreadMembers: transport.addThreadMembers?.bind(transport),
addReactions: transport.addReactions?.bind(transport),
pinMessage: transport.pinMessage?.bind(transport),
sendTyping: transport.sendTyping.bind(transport),
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -1,698 +0,0 @@
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { parse as parseToml } from "smol-toml";
import type {
ReasoningEffort,
ReasoningSummary,
v2,
} from "@peezy.tech/codex-flows/generated";
import type {
DiscordBridgeConfig,
DiscordConsoleOutputMode,
DiscordWorkspaceSurfaceConfig,
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",
]);
const defaultWorkspaceSurfaceKey = "default";
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);
const cwd = resolveHomeDir(
stringFlag(args, "dir") ??
stringFlag(args, "positional-dir") ??
env.CODEX_DISCORD_DIR ??
stringFlag(args, "cwd") ??
env.CODEX_DISCORD_CWD,
);
return {
type: "run",
discordToken,
appServerUrl,
localAppServer,
config: {
allowedUserIds,
allowedChannelIds: csvSet(
stringFlag(args, "allowed-channel-ids") ??
env.CODEX_DISCORD_ALLOWED_CHANNEL_IDS,
),
statePath,
workspace: workspaceConfig(args, env, cwd),
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,
hookSpoolDir: resolveHomeDir(
stringFlag(args, "hook-spool-dir") ??
env.CODEX_DISCORD_HOOK_SPOOL_DIR,
),
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 record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
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 workspaceConfig(
flags: Map<string, string | boolean>,
env: NodeJS.ProcessEnv,
workspaceRoot: string | undefined,
): DiscordBridgeConfig["workspace"] {
const workspaceSurfaces = workspaceSurfacesConfig(workspaceRoot);
const configuredHomeChannelId =
stringFlag(flags, "home-channel-id") ??
stringFlag(flags, "workspace-home-channel-id") ??
env.CODEX_DISCORD_HOME_CHANNEL_ID ??
env.CODEX_DISCORD_GATEWAY_HOME_CHANNEL_ID;
const useWorkspacePrimaryDefaults = !configuredHomeChannelId;
const homeChannelId = configuredHomeChannelId ??
workspaceSurfaces[0]?.homeChannelId;
const mainThreadId =
stringFlag(flags, "main-thread-id") ??
stringFlag(flags, "workspace-main-thread-id") ??
env.CODEX_DISCORD_MAIN_THREAD_ID ??
env.CODEX_DISCORD_GATEWAY_MAIN_THREAD_ID;
const configuredWorkspaceForumChannelId =
stringFlag(flags, "workspace-forum-channel-id") ??
stringFlag(flags, "workspace-workspace-forum-channel-id") ??
env.CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID ??
env.CODEX_DISCORD_GATEWAY_WORKSPACE_FORUM_CHANNEL_ID;
const workspaceForumChannelId = configuredWorkspaceForumChannelId ??
(useWorkspacePrimaryDefaults
? workspaceSurfaces[0]?.workspaceForumChannelId
: undefined);
const configuredTaskThreadsChannelId =
stringFlag(flags, "task-threads-channel-id") ??
stringFlag(flags, "workspace-task-threads-channel-id") ??
env.CODEX_DISCORD_TASK_THREADS_CHANNEL_ID ??
env.CODEX_DISCORD_GATEWAY_TASK_THREADS_CHANNEL_ID;
const taskThreadsChannelId = configuredTaskThreadsChannelId ??
(useWorkspacePrimaryDefaults
? workspaceSurfaces[0]?.taskThreadsChannelId
: undefined);
if (!homeChannelId) {
if (mainThreadId) {
throw new Error("Cannot set a workspace main thread without a workspace home channel.");
}
if (workspaceForumChannelId || taskThreadsChannelId) {
throw new Error("Cannot set Discord workbench channels without a workspace home channel.");
}
return undefined;
}
if (Boolean(workspaceForumChannelId) !== Boolean(taskThreadsChannelId)) {
throw new Error(
"Discord workbench requires both workspace forum and task threads channels.",
);
}
if (
workspaceForumChannelId &&
taskThreadsChannelId &&
(workspaceForumChannelId === homeChannelId ||
taskThreadsChannelId === homeChannelId ||
workspaceForumChannelId === taskThreadsChannelId)
) {
throw new Error(
"Discord workbench channels must be separate from the workspace home channel and each other.",
);
}
const defaultSurface = {
key: defaultWorkspaceSurfaceKey,
homeChannelId,
workspaceForumChannelId,
taskThreadsChannelId,
};
const surfaces = workspaceSurfaces.length > 0
? mergeWorkspaceSurfaces(
configuredHomeChannelId
? [defaultSurface, ...workspaceSurfaces]
: workspaceSurfaces,
)
: [];
return {
homeChannelId,
mainThreadId,
workspaceForumChannelId,
taskThreadsChannelId,
surfaces: surfaces.length > 0 ? surfaces : undefined,
};
}
function workspaceSurfacesConfig(
workspaceRoot: string | undefined,
): DiscordWorkspaceSurfaceConfig[] {
if (!workspaceRoot) {
return [];
}
const workspaceCwds = discoverWorkspaceConfigCwds(workspaceRoot);
if (workspaceCwds.length === 0) {
return [];
}
const surfaces: DiscordWorkspaceSurfaceConfig[] = [];
for (const workspaceCwd of workspaceCwds) {
const surface = workspaceWorkspaceSurfaceConfig(workspaceCwd);
if (surface) {
surfaces.push(surface);
}
}
return mergeWorkspaceSurfaces(surfaces);
}
function discoverWorkspaceConfigCwds(workspaceRoot: string): string[] {
const normalizedRoot = path.normalize(workspaceRoot);
const cwds = [normalizedRoot];
let entries;
try {
entries = readdirSync(normalizedRoot, { withFileTypes: true });
} catch {
return cwds;
}
for (const entry of entries) {
if (!isDiscoverableWorkspaceEntry(entry.name)) {
continue;
}
const fullPath = path.join(normalizedRoot, entry.name);
if (entry.isDirectory()) {
cwds.push(fullPath);
continue;
}
if (!entry.isSymbolicLink()) {
continue;
}
try {
if (statSync(fullPath).isDirectory()) {
cwds.push(fullPath);
}
} catch {
continue;
}
}
return uniqueStringList(cwds.map((cwd) => path.normalize(cwd))).sort(
(left, right) => left.localeCompare(right),
);
}
function isDiscoverableWorkspaceEntry(name: string): boolean {
return Boolean(name) &&
!name.startsWith(".") &&
name !== "node_modules";
}
function workspaceWorkspaceSurfaceConfig(
workspaceCwd: string,
): DiscordWorkspaceSurfaceConfig | undefined {
const configPath = path.join(workspaceCwd, ".codex", "workspace.toml");
if (!existsSync(configPath)) {
return undefined;
}
let parsed: unknown;
try {
parsed = parseToml(readFileSync(configPath, "utf8"));
} catch (error) {
throw new Error(
`Invalid workspace config TOML at ${configPath}: ${errorMessage(error)}`,
);
}
const surfacesInput = workspaceSurfaceEntries(parsed);
if (surfacesInput === undefined) {
return undefined;
}
if (!Array.isArray(surfacesInput)) {
throw new Error(
`workspace.toml discord.workspace.surfaces must be an array: ${configPath}`,
);
}
if (surfacesInput.length === 0) {
return undefined;
}
if (surfacesInput.length > 1) {
throw new Error(
`workspace.toml discord.workspace.surfaces must contain one surface: ${configPath}`,
);
}
return parseWorkspaceSurface(surfacesInput[0], 0, workspaceCwd);
}
function workspaceSurfaceEntries(input: unknown): unknown {
const parsed = record(input);
if (parsed.discord === undefined) {
return undefined;
}
const discord = record(parsed.discord);
if (discord.workspace === undefined) {
return undefined;
}
const workspace = record(discord.workspace);
return workspace.surfaces;
}
function parseWorkspaceSurface(
input: unknown,
index: number,
workspaceCwd: string,
): DiscordWorkspaceSurfaceConfig {
const parsed = record(input);
const key = optionalString(parsed.key) ?? optionalString(parsed.name);
const homeChannelId = optionalString(parsed.homeChannelId) ??
optionalString(parsed.home_channel_id);
const workspaceForumChannelId = optionalString(parsed.workspaceForumChannelId) ??
optionalString(parsed.workspace_forum_channel_id);
const taskThreadsChannelId = optionalString(parsed.taskThreadsChannelId) ??
optionalString(parsed.task_threads_channel_id);
if (!key) {
throw new Error(`Workspace surface at index ${index} is missing key.`);
}
if (!homeChannelId) {
throw new Error(`Workspace surface ${key} is missing homeChannelId.`);
}
if (Boolean(workspaceForumChannelId) !== Boolean(taskThreadsChannelId)) {
throw new Error(
`Workspace surface ${key} requires both workspaceForumChannelId and taskThreadsChannelId.`,
);
}
if (
workspaceForumChannelId &&
taskThreadsChannelId &&
(homeChannelId === workspaceForumChannelId ||
homeChannelId === taskThreadsChannelId ||
workspaceForumChannelId === taskThreadsChannelId)
) {
throw new Error(`Workspace surface ${key} channels must be distinct.`);
}
return {
key,
homeChannelId,
workspaceForumChannelId,
taskThreadsChannelId,
workspaceCwds: [path.normalize(workspaceCwd)],
};
}
function mergeWorkspaceSurfaces(
surfaces: DiscordWorkspaceSurfaceConfig[],
): DiscordWorkspaceSurfaceConfig[] {
const byKey = new Map<string, DiscordWorkspaceSurfaceConfig>();
for (const surface of surfaces) {
const existing = byKey.get(surface.key);
if (!existing) {
byKey.set(surface.key, {
...surface,
workspaceCwds: surface.workspaceCwds
? uniqueStringList(surface.workspaceCwds.map((cwd) => path.normalize(cwd)))
: undefined,
});
continue;
}
if (
existing.homeChannelId !== surface.homeChannelId ||
existing.workspaceForumChannelId !== surface.workspaceForumChannelId ||
existing.taskThreadsChannelId !== surface.taskThreadsChannelId
) {
throw new Error(
`Workspace surface key ${surface.key} is configured with different channels.`,
);
}
existing.workspaceCwds = existing.workspaceCwds && surface.workspaceCwds
? uniqueStringList([
...existing.workspaceCwds,
...surface.workspaceCwds.map((cwd) => path.normalize(cwd)),
])
: undefined;
}
const merged = [...byKey.values()];
validateWorkspaceSurfaces(merged);
return merged;
}
function validateWorkspaceSurfaces(surfaces: DiscordWorkspaceSurfaceConfig[]): void {
const channelIds = new Set<string>();
let catchAllSurfaces = 0;
for (const surface of surfaces) {
if (!surface.workspaceCwds || surface.workspaceCwds.length === 0) {
catchAllSurfaces += 1;
}
for (const channelId of [
surface.homeChannelId,
surface.workspaceForumChannelId,
surface.taskThreadsChannelId,
]) {
if (!channelId) {
continue;
}
if (channelIds.has(channelId)) {
throw new Error(
`Workspace surface channel is configured more than once: ${channelId}`,
);
}
channelIds.add(channelId);
}
}
if (catchAllSurfaces > 1) {
throw new Error("Only one workspace surface may omit workspaceCwds.");
}
}
function uniqueStringList(values: string[]): string[] {
return [...new Set(values)];
}
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
--home-channel-id <id> Enable workspace mode for one Discord home channel
--main-thread-id <id> Resume an existing Codex operator thread for workspace mode
--workspace-forum-channel-id <id>
Optional workbench forum channel for workspace posts
--task-threads-channel-id <id> Optional workbench text channel for task threads
--hook-spool-dir <path> Directory drained for Codex hook events
[dir] Optional Codex thread directory, resolved from home
--dir <path> Codex thread directory, resolved from home
--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);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

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

View file

@ -1,953 +0,0 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
Client,
Events,
GatewayIntentBits,
Partials,
type Interaction,
type ApplicationCommandDataResolvable,
type Message,
type MessageReaction,
type PartialMessageReaction,
type PartialUser,
type User,
} from "discord.js";
import { splitDiscordMessage } from "./bridge.ts";
import type { v2 } from "@peezy.tech/codex-flows/generated";
import {
createDiscordBridgeLogger,
type DiscordBridgeLogger,
} from "./logger.ts";
import type {
DiscordBridgeCommandRegistration,
DiscordEphemeralPicker,
DiscordBridgeTransport,
DiscordBridgeTransportHandlers,
} from "./types.ts";
export type DiscordJsBridgeTransportOptions = {
token: string;
logger?: DiscordBridgeLogger;
};
const threadPickerCustomIdPrefix = "codex_threads";
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.GuildMessageReactions,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.MessageContent,
],
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
});
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.MessageReactionAdd, (reaction, user) =>
void this.#handleReaction(reaction, user).catch((error) => {
this.#logger.error("discord.reaction.failed", {
error: errorMessage(error),
});
})
);
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(
options: DiscordBridgeCommandRegistration = {},
): Promise<void> {
const client = await this.#readyClient();
const commands = discordBridgeCommands();
const commandNames = commands.map(commandName);
const guildIds = await this.#guildIdsForCommandChannels(options.channelIds ?? []);
if (guildIds.length === 0) {
await client.application.commands.set(commands);
this.#logger.info("discord.commands.registered", {
scope: "global",
commands: commandNames,
});
return;
}
await client.application.commands.set([]);
this.#logger.info("discord.commands.registered", {
scope: "global-cleared",
commands: [],
});
for (const guildId of guildIds) {
const guild = await client.guilds.fetch(guildId);
await guild.commands.set(commands);
this.#logger.info("discord.commands.registered", {
scope: "guild",
guildId,
commands: commandNames,
});
}
}
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 createForumPost(
channelId: string,
name: string,
message: string,
): Promise<{ threadId: string; messageId?: string }> {
const client = this.#client;
if (!client) {
throw new Error("Discord bridge is not connected");
}
const channel = await client.channels.fetch(channelId);
if (!channel || typeof channel !== "object") {
throw new Error(`Discord channel cannot create forum posts: ${channelId}`);
}
const threads = getThreadsManager(channel as ThreadCreatableChannel);
if (!threads) {
throw new Error(`Discord channel cannot create forum posts: ${channelId}`);
}
const thread = await threads.create({
name,
autoArchiveDuration: 10080,
message: {
content: splitDiscordMessage(message)[0] ?? "",
allowedMentions: {
parse: [],
users: [],
roles: [],
repliedUser: false,
},
},
reason: "Codex Discord bridge workspace post",
});
if (!thread.id) {
throw new Error("Discord did not return a forum post thread id");
}
return { threadId: thread.id, messageId: thread.id };
}
async sendMessage(channelId: string, text: string): Promise<string[]> {
const channel = await this.#sendableChannel(channelId);
const messageIds: string[] = [];
const chunks = splitDiscordMessage(text);
for (const chunk of chunks) {
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 deleteWebhookMessages(
channelId: string,
options: { webhookUrl?: string } = {},
): Promise<{ deleted: number; failed: number }> {
const channel = await this.#sendableChannel(channelId);
const messages = getMessagesManager(channel);
if (!messages) {
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
}
const webhookId = options.webhookUrl
? webhookIdFromUrl(options.webhookUrl)
: undefined;
let before: string | undefined;
let deleted = 0;
let failed = 0;
for (;;) {
const batch = await messages.fetch({ limit: 100, before });
const fetched = [...batch.values()];
if (fetched.length === 0) {
break;
}
for (const message of fetched) {
if (!message.webhookId) {
continue;
}
if (webhookId && message.webhookId !== webhookId) {
continue;
}
try {
await message.delete();
deleted += 1;
} catch (error) {
failed += 1;
this.#logger.debug("discord.webhookMessage.deleteFailed", {
channelId,
messageId: message.id,
error: errorMessage(error),
});
}
}
before = fetched[fetched.length - 1]?.id;
if (fetched.length < 100 || !before) {
break;
}
}
return { deleted, failed };
}
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 addReactions(
channelId: string,
messageId: string,
reactions: 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.react) {
throw new Error(`Discord message cannot receive reactions: ${messageId}`);
}
for (const reaction of reactions) {
await message.react(reaction);
}
}
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 #handleReaction(
reaction: MessageReaction | PartialMessageReaction,
user: User | PartialUser,
): Promise<void> {
if (user.bot) {
return;
}
const fullReaction = reaction.partial ? await reaction.fetch() : reaction;
const message = fullReaction.message.partial
? await fullReaction.message.fetch()
: fullReaction.message;
const emoji = fullReaction.emoji.name ?? fullReaction.emoji.id;
if (!emoji) {
return;
}
this.#handlers?.onInbound({
kind: "reaction",
channelId: message.channelId,
guildId: message.guildId ?? undefined,
messageId: message.id,
emoji,
author: {
id: user.id,
name: user.globalName ?? user.username ?? user.id,
isBot: user.bot,
},
createdAt: new Date().toISOString(),
});
}
async #handleInteraction(interaction: Interaction): Promise<void> {
if (interaction.isButton()) {
const selection = threadPickerSelectionFromCustomId(interaction.customId);
if (!selection) {
return;
}
await interaction.deferUpdate();
const reply = async (text: string) => {
await interaction.followUp({
content: text,
ephemeral: true,
allowedMentions: emptyAllowedMentions(),
});
};
const update = async (text: string) => {
await interaction.editReply({
content: text,
components: [],
allowedMentions: emptyAllowedMentions(),
});
};
const updatePicker = async (picker: DiscordEphemeralPicker) => {
await interaction.editReply({
content: picker.text,
components: threadPickerComponents(picker),
allowedMentions: emptyAllowedMentions(),
});
};
this.#handlers?.onInbound({
kind: "threadPicker",
channelId: interaction.channelId,
guildId: interaction.guildId ?? undefined,
pickerId: selection.pickerId,
optionId: selection.optionId,
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,
update,
updatePicker,
});
return;
}
if (!interaction.isChatInputCommand()) {
return;
}
if (
interaction.commandName !== "clear" &&
interaction.commandName !== "clear-webhooks" &&
interaction.commandName !== "status" &&
interaction.commandName !== "threads" &&
interaction.commandName !== "goals"
) {
return;
}
const channelId = interaction.channelId;
if (
interaction.commandName === "status" ||
interaction.commandName === "threads" ||
interaction.commandName === "goals"
) {
await interaction.deferReply({ ephemeral: true });
}
const reply = async (text: string) => {
const payload = {
content: text,
allowedMentions: {
parse: [],
users: [],
roles: [],
repliedUser: false,
},
};
if (interaction.deferred || interaction.replied) {
await interaction.editReply(payload);
return;
}
await interaction.reply({ ...payload, ephemeral: true });
};
if (interaction.commandName === "clear-webhooks") {
const webhookUrl = interaction.options.getString("webhook_url") ?? undefined;
this.#handlers?.onInbound({
kind: "clearWebhooks",
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,
},
webhookUrl,
createdAt: new Date().toISOString(),
reply,
});
return;
}
if (interaction.commandName === "status") {
const replyPicker = async (picker: DiscordEphemeralPicker) => {
const payload = {
content: picker.text,
components: threadPickerComponents(picker),
allowedMentions: emptyAllowedMentions(),
};
if (interaction.deferred || interaction.replied) {
await interaction.editReply(payload);
return;
}
await interaction.reply({ ...payload, ephemeral: true });
};
this.#handlers?.onInbound({
kind: "status",
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,
replyPicker,
});
return;
}
if (interaction.commandName === "threads") {
const replyPicker = async (picker: DiscordEphemeralPicker) => {
const payload = {
content: picker.text,
components: threadPickerComponents(picker),
allowedMentions: emptyAllowedMentions(),
};
if (interaction.deferred || interaction.replied) {
await interaction.editReply(payload);
return;
}
await interaction.reply({ ...payload, ephemeral: true });
};
this.#handlers?.onInbound({
kind: "threads",
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,
replyPicker,
});
return;
}
if (interaction.commandName === "goals") {
const goalStatus = goalStatusFromString(
interaction.options.getString("status"),
);
const objective = interaction.options.getString("objective")?.trim() ||
undefined;
const tokenBudget = interaction.options.getInteger("token_budget") ??
undefined;
const clear = interaction.options.getBoolean("clear") ?? undefined;
const replyPicker = async (picker: DiscordEphemeralPicker) => {
const payload = {
content: picker.text,
components: threadPickerComponents(picker),
allowedMentions: emptyAllowedMentions(),
};
if (interaction.deferred || interaction.replied) {
await interaction.editReply(payload);
return;
}
await interaction.reply({ ...payload, ephemeral: true });
};
this.#handlers?.onInbound({
kind: "goals",
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(),
objective,
goalStatus,
tokenBudget,
clear,
reply,
replyPicker,
});
return;
}
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 #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;
}
async #readyClient(): Promise<Client<true>> {
const client = this.#client;
if (!client) {
throw new Error("Discord bridge is not connected");
}
if (client.isReady()) {
return client;
}
return await new Promise<Client<true>>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timed out waiting for Discord client ready event"));
}, 15_000);
client.once(Events.ClientReady, (readyClient) => {
clearTimeout(timeout);
resolve(readyClient);
});
});
}
async #guildIdsForCommandChannels(channelIds: string[]): Promise<string[]> {
const client = await this.#readyClient();
const guildIds = new Set<string>();
for (const channelId of channelIds) {
try {
const channel = await client.channels.fetch(channelId);
const guildId = guildIdFromChannel(channel);
if (guildId) {
guildIds.add(guildId);
}
} catch (error) {
this.#logger.debug("discord.commands.channelFetchFailed", {
channelId,
error: errorMessage(error),
});
}
}
return [...guildIds];
}
}
function discordBridgeCommands(): ApplicationCommandDataResolvable[] {
return [
{
name: "clear",
description: "Delete inactive Codex bridge threads",
},
{
name: "clear-webhooks",
description: "Delete webhook-authored messages in this channel",
options: [
{
name: "webhook_url",
description: "Optional webhook URL to target a single webhook",
type: 3,
required: false,
},
],
},
{
name: "status",
description: "Show Codex workspace status",
},
{
name: "threads",
description: "List Codex threads for this workspace",
},
{
name: "goals",
description: "Manage Codex goals for this workspace or thread",
options: [
{
name: "objective",
description: "Create or update this thread's goal objective",
type: 3,
required: false,
},
{
name: "status",
description: "Set this thread's goal status",
type: 3,
required: false,
choices: [
{ name: "active", value: "active" },
{ name: "paused", value: "paused" },
{ name: "budget limited", value: "budgetLimited" },
{ name: "complete", value: "complete" },
],
},
{
name: "token_budget",
description: "Optional token budget for this thread's goal",
type: 4,
required: false,
},
{
name: "clear",
description: "Clear this thread's goal",
type: 5,
required: false,
},
],
},
];
}
function commandName(command: ApplicationCommandDataResolvable): string {
return typeof command === "object" && command !== null && "name" in command
? String(command.name)
: "unknown";
}
function goalStatusFromString(value: string | null): v2.ThreadGoalStatus | undefined {
if (
value === "active" ||
value === "paused" ||
value === "budgetLimited" ||
value === "complete"
) {
return value;
}
return undefined;
}
function threadPickerComponents(
picker: DiscordEphemeralPicker,
): ActionRowBuilder<ButtonBuilder>[] {
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
for (let index = 0; index < picker.options.length; index += 5) {
const row = new ActionRowBuilder<ButtonBuilder>();
for (const option of picker.options.slice(index, index + 5)) {
row.addComponents(
new ButtonBuilder()
.setCustomId(threadPickerCustomId(picker.pickerId, option.id))
.setLabel(option.label)
.setStyle(ButtonStyle.Secondary),
);
}
rows.push(row);
}
return rows;
}
function threadPickerCustomId(pickerId: string, optionId: string): string {
return `${threadPickerCustomIdPrefix}:${pickerId}:${optionId}`;
}
function threadPickerSelectionFromCustomId(
customId: string,
): { pickerId: string; optionId: string } | undefined {
const prefix = `${threadPickerCustomIdPrefix}:`;
if (!customId.startsWith(prefix)) {
return undefined;
}
const rest = customId.slice(prefix.length);
const [pickerId, optionId, ...extra] = rest.split(":");
if (!pickerId || !optionId || extra.length > 0) {
return undefined;
}
return { pickerId, optionId };
}
function emptyAllowedMentions(): Record<string, unknown> {
return {
parse: [],
users: [],
roles: [],
repliedUser: false,
};
}
function guildIdFromChannel(channel: unknown): string | undefined {
if (!channel || typeof channel !== "object") {
return undefined;
}
const candidate = channel as {
guildId?: unknown;
guild?: { id?: unknown };
};
if (typeof candidate.guildId === "string") {
return candidate.guildId;
}
if (typeof candidate.guild?.id === "string") {
return candidate.guild.id;
}
return undefined;
}
type ThreadCreateOptions = {
name: string;
autoArchiveDuration?: number;
message?: Record<string, unknown>;
reason?: string;
};
type ThreadCreatableChannel = {
id: string;
threads?: {
create(options: ThreadCreateOptions): Promise<{ id?: string }>;
};
};
type SendableChannel = ThreadCreatableChannel & {
send(options: Record<string, unknown>): Promise<{ id?: string }>;
sendTyping?: () => Promise<void>;
members?: {
add(userId: string): Promise<unknown>;
};
messages?: {
fetch(messageId: string): Promise<DiscordFetchedMessage>;
fetch(options: {
limit: number;
before?: string;
}): Promise<{ values(): IterableIterator<DiscordFetchedMessage> }>;
};
};
type DiscordFetchedMessage = {
id: string;
webhookId?: string | null;
delete(): Promise<unknown>;
edit(options: Record<string, unknown>): Promise<unknown>;
pinned?: boolean;
pin?(): Promise<unknown>;
react?(reaction: string): Promise<unknown>;
startThread?(options: ThreadCreateOptions): Promise<{ id?: string }>;
};
function getThreadsManager(
channel: ThreadCreatableChannel,
): ThreadCreatableChannel["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 webhookIdFromUrl(webhookUrl: string): string {
const parsed = new URL(webhookUrl);
const match = parsed.pathname.match(/\/webhooks\/(\d+)(?:\/|$)/);
if (!match?.[1]) {
throw new Error("Discord webhook URL does not include a webhook id");
}
return match[1];
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -1,335 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { writeHookSpoolEvent } from "./stop-hook-spool.ts";
const defaultHookCommand = "codex-discord-bridge hook event";
const defaultDlxPackage = "@peezy.tech/codex-discord-bridge";
const workspaceHookEvents = [
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"Stop",
] as const;
export type HookInstallOptions = {
command?: string;
useDlx?: boolean;
dlxPackage?: string;
configPath?: string;
hooksPath?: string;
dryRun?: boolean;
};
export type HookInstallResult = {
command: string;
configPath: string;
hooksPath: string;
dryRun: boolean;
};
export async function handleHookCommand(argv: string[]): Promise<boolean> {
if (argv[0] !== "hook") {
return false;
}
const subcommand = argv[1] ?? "help";
if (subcommand === "event" || subcommand === "stop") {
await runHookEvent();
return true;
}
if (subcommand === "install") {
const result = await installStopHook(parseInstallArgs(argv.slice(2)));
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return true;
}
if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
process.stdout.write(hookHelpText());
return true;
}
throw new Error(`Unknown hook subcommand: ${subcommand}`);
}
export async function runHookEvent(): Promise<void> {
let input = "";
try {
input = await readStdinText();
const parsed = JSON.parse(input);
const event = await writeHookSpoolEvent(parsed);
if (eventSupportsContinueOutput(event.eventName)) {
process.stdout.write(`${JSON.stringify({ continue: true })}\n`);
}
} catch (error) {
process.stderr.write(`discord workspace hook failed: ${errorMessage(error)}\n`);
if (eventSupportsContinueOutput(eventNameFromHookInput(input))) {
process.stdout.write(`${JSON.stringify({ continue: true })}\n`);
}
}
}
export const runStopHook = runHookEvent;
async function readStdinText(): Promise<string> {
const chunks: Uint8Array[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
export async function installStopHook(
options: HookInstallOptions = {},
): Promise<HookInstallResult> {
const configPath = path.resolve(
expandHome(options.configPath ?? path.join(os.homedir(), ".codex", "config.toml")),
);
const hooksPath = path.resolve(
expandHome(options.hooksPath ?? path.join(os.homedir(), ".codex", "hooks.json")),
);
const command = hookCommand(options);
if (!options.dryRun) {
const configText = await readTextIfExists(configPath);
const hooksText = await readTextIfExists(hooksPath);
await mkdir(path.dirname(configPath), { recursive: true });
await mkdir(path.dirname(hooksPath), { recursive: true });
await writeFile(configPath, enableHooksFeature(configText));
await writeFile(hooksPath, `${JSON.stringify(upsertStopHookConfig(hooksText, command), null, 2)}\n`);
}
return {
command,
configPath,
hooksPath,
dryRun: Boolean(options.dryRun),
};
}
export function enableHooksFeature(configText: string): string {
const lines = configText.replace(/\s*$/, "").split(/\r?\n/);
if (lines.length === 1 && lines[0] === "") {
return "[features]\nhooks = true\n";
}
const featureHeaderIndex = lines.findIndex((line) => line.trim() === "[features]");
if (featureHeaderIndex < 0) {
return `${lines.join("\n")}\n\n[features]\nhooks = true\n`;
}
let insertIndex = featureHeaderIndex + 1;
while (insertIndex < lines.length && !lines[insertIndex]?.trim().startsWith("[")) {
const line = lines[insertIndex] ?? "";
if (/^\s*hooks\s*=/.test(line)) {
lines[insertIndex] = "hooks = true";
return `${lines.join("\n")}\n`;
}
insertIndex += 1;
}
lines.splice(featureHeaderIndex + 1, 0, "hooks = true");
return `${lines.join("\n")}\n`;
}
export function upsertStopHookConfig(
hooksText: string,
command: string,
): Record<string, unknown> {
const config = parseHooksJson(hooksText);
const hooks = record(config.hooks);
for (const eventName of workspaceHookEvents) {
const groups = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
hooks[eventName] = [
hookGroup(command),
...groups
.map(removeWorkspaceStopHookHandlers)
.filter((group): group is Record<string, unknown> => group !== undefined),
];
}
config.hooks = hooks;
return config;
}
function parseInstallArgs(argv: string[]): HookInstallOptions {
const options: HookInstallOptions = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg) {
continue;
}
if (arg === "--dry-run") {
options.dryRun = true;
continue;
}
if (arg === "--dlx") {
options.useDlx = true;
continue;
}
if (arg === "--command") {
options.command = requiredNext(argv, ++index, arg);
continue;
}
if (arg.startsWith("--command=")) {
options.command = arg.slice("--command=".length);
continue;
}
if (arg === "--dlx-package") {
options.useDlx = true;
options.dlxPackage = requiredNext(argv, ++index, arg);
continue;
}
if (arg.startsWith("--dlx-package=")) {
options.useDlx = true;
options.dlxPackage = arg.slice("--dlx-package=".length);
continue;
}
if (arg === "--config-path") {
options.configPath = requiredNext(argv, ++index, arg);
continue;
}
if (arg.startsWith("--config-path=")) {
options.configPath = arg.slice("--config-path=".length);
continue;
}
if (arg === "--hooks-path") {
options.hooksPath = requiredNext(argv, ++index, arg);
continue;
}
if (arg.startsWith("--hooks-path=")) {
options.hooksPath = arg.slice("--hooks-path=".length);
continue;
}
throw new Error(`Unknown hook install option: ${arg}`);
}
return options;
}
function hookCommand(options: HookInstallOptions): string {
if (options.command && options.useDlx) {
throw new Error("Cannot set both --command and --dlx.");
}
if (options.command) {
return options.command;
}
if (options.useDlx || options.dlxPackage) {
return `vp dlx ${options.dlxPackage ?? defaultDlxPackage} ${defaultHookCommand}`;
}
return defaultHookCommand;
}
function hookGroup(command: string): Record<string, unknown> {
return {
hooks: [
{
type: "command",
command,
timeout: 10,
},
],
};
}
function removeWorkspaceStopHookHandlers(input: unknown): Record<string, unknown> | undefined {
const group = record(input);
const handlers = Array.isArray(group.hooks)
? group.hooks.filter((handler) => !isWorkspaceStopHookHandler(handler))
: [];
if (handlers.length === 0) {
return undefined;
}
return { ...group, hooks: handlers };
}
function isWorkspaceStopHookHandler(input: unknown): boolean {
const handler = record(input);
const command = typeof handler.command === "string" ? handler.command : "";
return command.includes("codex-discord-bridge hook stop") ||
command.includes("codex-discord-bridge hook event") ||
command.includes("codex-discord-workspace-stop-hook") ||
/apps\/discord-bridge\/(?:dist\/index\.js|src\/(?:index|stop-hook)\.ts)\s+hook\s+(?:event|stop)\b/.test(command);
}
function eventSupportsContinueOutput(eventName: string): boolean {
return eventName === "SessionStart" ||
eventName === "UserPromptSubmit" ||
eventName === "Stop";
}
function eventNameFromHookInput(input: string): string {
try {
const parsed = record(JSON.parse(input));
return typeof parsed.hook_event_name === "string"
? parsed.hook_event_name
: typeof parsed.eventName === "string"
? parsed.eventName
: "";
} catch {
return "";
}
}
async function readTextIfExists(filePath: string): Promise<string> {
try {
return await readFile(filePath, "utf8");
} catch (error) {
const code = error instanceof Error && "code" in error
? String((error as NodeJS.ErrnoException).code)
: "";
if (code === "ENOENT") {
return "";
}
throw error;
}
}
function parseHooksJson(text: string): Record<string, unknown> {
if (!text.trim()) {
return {};
}
try {
return record(JSON.parse(text));
} catch (error) {
throw new Error(`Failed to parse hooks.json: ${errorMessage(error)}`);
}
}
function expandHome(value: string): string {
if (value === "~") {
return os.homedir();
}
if (value.startsWith("~/")) {
return path.join(os.homedir(), value.slice(2));
}
return value;
}
function requiredNext(argv: string[], index: number, flag: string): string {
const value = argv[index];
if (!value) {
throw new Error(`${flag} requires a value.`);
}
return value;
}
function hookHelpText(): string {
return `codex-discord-bridge hook manages the global Codex observability hooks.
Usage:
codex-discord-bridge hook install [options]
codex-discord-bridge hook event
Options:
--command <cmd> Hook command to write. Defaults to "codex-discord-bridge hook event".
--dlx Write a VitePlus package-on-demand command instead of the global binary command.
--dlx-package <pkg> Package for vp dlx. Defaults to @peezy.tech/codex-discord-bridge.
--config-path <path> Codex config.toml path.
--hooks-path <path> Codex hooks.json path.
--dry-run Print the planned install result without writing files.
`;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -1,100 +0,0 @@
#!/usr/bin/env node
import type { DiscordCodexBridge } from "./bridge.ts";
import { handleHookCommand } from "./hook-cli.ts";
import { parseConfig } from "./config.ts";
import { createDiscordBridgeLogger } from "./logger.ts";
async function main(): Promise<void> {
let logger = createDiscordBridgeLogger();
try {
const argv = process.argv.slice(2);
if (await handleHookCommand(argv)) {
return;
}
const parsed = parseConfig(argv, process.env);
if (parsed.type === "help") {
process.stdout.write(parsed.text);
return;
}
logger = createDiscordBridgeLogger({
debug: parsed.config.debug,
logLevel: parsed.config.logLevel,
});
const { CodexAppServerClient, CodexStdioTransport } = await import(
"@peezy.tech/codex-flows"
);
const { DiscordCodexBridge } = await import("./bridge.ts");
const { createDiscordConsoleOutput } = await import("./console-output.ts");
const { DiscordJsBridgeTransport } = await import("./discord-transport.ts");
const { JsonFileStateStore } = await import("./state.ts");
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();

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,225 +0,0 @@
#!/usr/bin/env node
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(process.argv.slice(2), process.stdin, process.stdout);
} catch (error) {
process.stderr.write(
`pretty-log failed: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
process.exitCode = 1;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,456 +0,0 @@
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import type {
DiscordBridgeActiveTurn,
DiscordBridgeDelivery,
DiscordBridgeQueueItem,
DiscordBridgeSession,
DiscordBridgeState,
DiscordBridgeStateStore,
DiscordWorkspaceDelegation,
DiscordWorkspaceHookEventName,
DiscordWorkspaceObservedThread,
DiscordWorkspaceWorkspaceSurface,
DiscordWorkspaceState,
} from "./types.ts";
const maxProcessedMessageIds = 1000;
const maxDeliveries = 500;
const maxProcessedStopHookEventIds = 2000;
const maxProcessedHookEventIds = 5000;
const maxObservedThreads = 1000;
export class JsonFileStateStore implements DiscordBridgeStateStore {
readonly path: string;
constructor(filePath: string) {
this.path = path.resolve(filePath);
}
async load(): Promise<DiscordBridgeState> {
if (!(await exists(this.path))) {
return emptyState();
}
const parsed = JSON.parse(await readFile(this.path, "utf8")) 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);
}
}
async function exists(pathValue: string): Promise<boolean> {
try {
await stat(pathValue);
return true;
} catch {
return false;
}
}
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,
workspace: undefined,
sessions: [],
queue: [],
activeTurns: [],
processedMessageIds: [],
deliveries: [],
};
}
export function trimState(state: DiscordBridgeState): void {
state.processedMessageIds = state.processedMessageIds.slice(
-maxProcessedMessageIds,
);
state.deliveries = state.deliveries.slice(-maxDeliveries);
if (state.workspace?.processedStopHookEventIds) {
state.workspace.processedStopHookEventIds =
state.workspace.processedStopHookEventIds.slice(
-maxProcessedStopHookEventIds,
);
}
if (state.workspace?.processedHookEventIds) {
state.workspace.processedHookEventIds =
state.workspace.processedHookEventIds.slice(-maxProcessedHookEventIds);
}
if (state.workspace?.observedThreads) {
state.workspace.observedThreads = [...state.workspace.observedThreads]
.sort((left, right) =>
Date.parse(right.lastSeenAt) - Date.parse(left.lastSeenAt)
)
.slice(0, maxObservedThreads);
}
}
function parseState(value: unknown): DiscordBridgeState {
if (!isRecord(value) || value.version !== 1) {
throw new Error("Invalid Discord bridge state file");
}
return {
version: 1,
workspace: parseWorkspace(value.workspace),
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 parseWorkspace(value: unknown): DiscordWorkspaceState | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge workspace state");
}
return {
homeChannelId: requiredString(value.homeChannelId, "workspace.homeChannelId"),
mainThreadId: optionalString(value.mainThreadId),
statusMessageId: optionalString(value.statusMessageId),
createdAt: optionalString(value.createdAt),
toolsVersion: optionalNumber(value.toolsVersion),
delegations: Array.isArray(value.delegations)
? value.delegations.map(parseWorkspaceDelegation)
: [],
workspaces: Array.isArray(value.workspaces)
? value.workspaces.map(parseWorkspaceWorkspace)
: [],
observedThreads: Array.isArray(value.observedThreads)
? value.observedThreads.map(parseWorkspaceObservedThread)
: [],
pendingWakes: Array.isArray(value.pendingWakes)
? value.pendingWakes.map(parseWorkspacePendingWake)
: [],
processedHookEventIds: uniqueStrings([
...(Array.isArray(value.processedHookEventIds)
? value.processedHookEventIds
: []),
...(Array.isArray(value.processedStopHookEventIds)
? value.processedStopHookEventIds
: []),
]),
processedStopHookEventIds: Array.isArray(value.processedStopHookEventIds)
? uniqueStrings(value.processedStopHookEventIds)
: [],
};
}
function parseWorkspaceDelegation(value: unknown): DiscordWorkspaceDelegation {
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge workspace delegation");
}
const status = value.status;
if (
status !== "active" &&
status !== "idle" &&
status !== "failed" &&
status !== "complete" &&
status !== "reported"
) {
throw new Error("Invalid Discord bridge workspace delegation status");
}
return {
id: requiredString(value.id, "workspace.delegations.id"),
codexThreadId: requiredString(
value.codexThreadId,
"workspace.delegations.codexThreadId",
),
title: requiredString(value.title, "workspace.delegations.title"),
status,
cwd: optionalString(value.cwd),
workspaceKey: optionalString(value.workspaceKey),
surfaceKey: optionalString(value.surfaceKey),
groupId: optionalString(value.groupId),
returnMode: parseReturnMode(value.returnMode),
discordDetailThreadId: optionalString(value.discordDetailThreadId),
discordTaskThreadId: optionalString(value.discordTaskThreadId),
discordWorkspaceThreadId: optionalString(value.discordWorkspaceThreadId),
parentDiscordMessageId: optionalString(value.parentDiscordMessageId),
lastTurnId: optionalString(value.lastTurnId),
lastStatus: optionalString(value.lastStatus),
lastFinal: optionalString(value.lastFinal),
completedAt: optionalString(value.completedAt),
injectedAt: optionalString(value.injectedAt),
mirroredAt: optionalString(value.mirroredAt),
taskMirroredAt: optionalString(value.taskMirroredAt),
reportedAt: optionalString(value.reportedAt),
createdAt: requiredString(value.createdAt, "workspace.delegations.createdAt"),
updatedAt: requiredString(value.updatedAt, "workspace.delegations.updatedAt"),
};
}
function parseWorkspaceWorkspace(value: unknown): DiscordWorkspaceWorkspaceSurface {
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge workspace workspace");
}
return {
key: requiredString(value.key, "workspace.workspaces.key"),
surfaceKey: optionalString(value.surfaceKey),
cwd: requiredString(value.cwd, "workspace.workspaces.cwd"),
title: requiredString(value.title, "workspace.workspaces.title"),
discordThreadId: requiredString(
value.discordThreadId,
"workspace.workspaces.discordThreadId",
),
statusMessageId: optionalString(value.statusMessageId),
delegationIds: Array.isArray(value.delegationIds)
? uniqueStrings(value.delegationIds)
: [],
createdAt: requiredString(value.createdAt, "workspace.workspaces.createdAt"),
updatedAt: requiredString(value.updatedAt, "workspace.workspaces.updatedAt"),
};
}
function parseWorkspacePendingWake(
value: unknown,
): NonNullable<DiscordWorkspaceState["pendingWakes"]>[number] {
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge workspace pending wake");
}
const kind = value.kind === "delegation" || value.kind === "group"
? value.kind
: undefined;
if (!kind) {
throw new Error("Invalid Discord bridge workspace pending wake kind");
}
return {
id: requiredString(value.id, "workspace.pendingWakes.id"),
kind,
delegationIds: Array.isArray(value.delegationIds)
? uniqueStrings(value.delegationIds)
: [],
groupId: optionalString(value.groupId),
reason: requiredString(value.reason, "workspace.pendingWakes.reason"),
createdAt: requiredString(value.createdAt, "workspace.pendingWakes.createdAt"),
startedAt: optionalString(value.startedAt),
};
}
function parseWorkspaceObservedThread(value: unknown): DiscordWorkspaceObservedThread {
if (!isRecord(value)) {
throw new Error("Invalid Discord bridge workspace observed thread");
}
return {
threadId: requiredString(value.threadId, "workspace.observedThreads.threadId"),
title: optionalString(value.title),
status: parseObservedThreadStatus(value.status),
cwd: optionalString(value.cwd),
workspaceKey: optionalString(value.workspaceKey),
surfaceKey: optionalString(value.surfaceKey),
model: optionalString(value.model),
transcriptPath: optionalString(value.transcriptPath),
lastTurnId: optionalString(value.lastTurnId),
lastHookEventName: parseHookEventName(value.lastHookEventName),
source: optionalString(value.source),
promptPreview: optionalString(value.promptPreview),
assistantPreview: optionalString(value.assistantPreview),
toolName: optionalString(value.toolName),
toolUseId: optionalString(value.toolUseId),
toolInputPreview: optionalString(value.toolInputPreview),
toolResponsePreview: optionalString(value.toolResponsePreview),
permissionDescription: optionalString(value.permissionDescription),
firstSeenAt: requiredString(value.firstSeenAt, "workspace.observedThreads.firstSeenAt"),
lastSeenAt: requiredString(value.lastSeenAt, "workspace.observedThreads.lastSeenAt"),
updatedAt: requiredString(value.updatedAt, "workspace.observedThreads.updatedAt"),
};
}
function parseObservedThreadStatus(
value: unknown,
): DiscordWorkspaceObservedThread["status"] {
return value === "starting" ||
value === "active" ||
value === "tool" ||
value === "waiting" ||
value === "idle"
? value
: "idle";
}
function parseHookEventName(value: unknown): DiscordWorkspaceHookEventName | undefined {
return value === "SessionStart" ||
value === "UserPromptSubmit" ||
value === "PreToolUse" ||
value === "PermissionRequest" ||
value === "PostToolUse" ||
value === "Stop"
? value
: undefined;
}
function parseReturnMode(value: unknown): DiscordWorkspaceDelegation["returnMode"] {
return value === "detached" ||
value === "record_only" ||
value === "wake_on_done" ||
value === "wake_on_group" ||
value === "manual"
? value
: undefined;
}
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),
surfaceKey: optionalString(value.surfaceKey),
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 === "workspace" ||
value === "delegated" ||
value === "workspace"
? value
: undefined;
}
function uniqueStrings(values: unknown[]): string[] {
return [...new Set(values.filter(
(candidate): candidate is string => typeof candidate === "string" && candidate.length > 0,
))];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -1,317 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import {
mkdir,
readdir,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
DiscordWorkspaceHookEvent,
DiscordWorkspaceHookEventName,
} from "./types.ts";
export type HookEventSpoolDisposition = "processed" | "ignored" | "failed";
export type StopHookSpoolDisposition = HookEventSpoolDisposition;
export type PendingHookEventSpoolFile =
| {
filePath: string;
fileName: string;
event: DiscordWorkspaceHookEvent;
}
| {
filePath: string;
fileName: string;
error: Error;
};
export type PendingStopHookSpoolFile = PendingHookEventSpoolFile;
export function defaultStopHookSpoolDir(): string {
return path.join(os.homedir(), ".codex", "discord-bridge", "stop-hooks");
}
export function stopHookSpoolDirFromEnv(
env: NodeJS.ProcessEnv = process.env,
): string {
return env.CODEX_DISCORD_HOOK_SPOOL_DIR || defaultStopHookSpoolDir();
}
export function stopHookSpoolPaths(spoolDir: string): Record<
"pending" | HookEventSpoolDisposition,
string
> {
const root = path.resolve(spoolDir);
return {
pending: path.join(root, "pending"),
processed: path.join(root, "processed"),
ignored: path.join(root, "ignored"),
failed: path.join(root, "failed"),
};
}
export async function ensureStopHookSpool(spoolDir: string): Promise<void> {
const paths = stopHookSpoolPaths(spoolDir);
await Promise.all(Object.values(paths).map((dir) => mkdir(dir, { recursive: true })));
}
export async function writeStopHookSpoolEvent(
input: unknown,
options: {
spoolDir?: string;
now?: () => Date;
} = {},
): Promise<DiscordWorkspaceHookEvent> {
return await writeHookSpoolEvent(input, options);
}
export async function writeHookSpoolEvent(
input: unknown,
options: {
spoolDir?: string;
now?: () => Date;
} = {},
): Promise<DiscordWorkspaceHookEvent> {
const spoolDir = options.spoolDir ?? stopHookSpoolDirFromEnv();
const event = hookEventFromInput(input, options.now ?? (() => new Date()));
const paths = stopHookSpoolPaths(spoolDir);
await mkdir(paths.pending, { recursive: true });
const fileName = `${event.id}.json`;
const finalPath = path.join(paths.pending, fileName);
const tempPath = path.join(
paths.pending,
`.${fileName}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`,
);
await writeFile(tempPath, `${JSON.stringify(event, null, 2)}\n`);
await rename(tempPath, finalPath);
return event;
}
export async function readPendingStopHookSpoolFiles(
spoolDir: string,
): Promise<PendingHookEventSpoolFile[]> {
const paths = stopHookSpoolPaths(spoolDir);
await ensureStopHookSpool(spoolDir);
const fileNames = (await readdir(paths.pending))
.filter((fileName) => fileName.endsWith(".json"))
.sort();
const files: PendingHookEventSpoolFile[] = [];
for (const fileName of fileNames) {
const filePath = path.join(paths.pending, fileName);
try {
const parsed = JSON.parse(await readFile(filePath, "utf8")) as unknown;
files.push({
filePath,
fileName,
event: parseHookSpoolEvent(parsed),
});
} catch (error) {
files.push({
filePath,
fileName,
error: error instanceof Error ? error : new Error(String(error)),
});
}
}
return files;
}
export async function archiveStopHookSpoolFile(
file: Pick<PendingHookEventSpoolFile, "filePath" | "fileName">,
spoolDir: string,
disposition: HookEventSpoolDisposition,
): Promise<void> {
const paths = stopHookSpoolPaths(spoolDir);
await mkdir(paths[disposition], { recursive: true });
const target = path.join(
paths[disposition],
`${Date.now()}-${randomUUID()}-${file.fileName}`,
);
try {
await rename(file.filePath, target);
} catch (error) {
const code = error instanceof Error && "code" in error
? String((error as NodeJS.ErrnoException).code)
: "";
if (code === "ENOENT") {
return;
}
throw error;
}
}
export async function removeStopHookSpool(spoolDir: string): Promise<void> {
await rm(path.resolve(spoolDir), { recursive: true, force: true });
}
function hookEventFromInput(
input: unknown,
now: () => Date,
): DiscordWorkspaceHookEvent {
const parsed = record(input);
const eventName = stringValue(parsed.hook_event_name) ?? stringValue(parsed.eventName);
if (!isHookEventName(eventName)) {
throw new Error(`Unsupported hook event: ${eventName}`);
}
const sessionId = stringValue(parsed.session_id) ?? stringValue(parsed.sessionId);
if (!sessionId) {
throw new Error("Hook input is missing session_id");
}
const turnId = stringValue(parsed.turn_id) ?? stringValue(parsed.turnId);
const transcriptPath =
stringValue(parsed.transcript_path) ?? stringValue(parsed.transcriptPath);
const cwd = stringValue(parsed.cwd);
const model = stringValue(parsed.model);
const source = stringValue(parsed.source);
const toolName = stringValue(parsed.tool_name) ?? stringValue(parsed.toolName);
const toolUseId = stringValue(parsed.tool_use_id) ?? stringValue(parsed.toolUseId);
const toolInput = parsed.tool_input ?? parsed.toolInput;
const toolResponse = parsed.tool_response ?? parsed.toolResponse;
const lastAssistantMessage =
nullableString(parsed.last_assistant_message) ??
nullableString(parsed.lastAssistantMessage);
const stopHookActive =
typeof parsed.stop_hook_active === "boolean"
? parsed.stop_hook_active
: typeof parsed.stopHookActive === "boolean"
? parsed.stopHookActive
: undefined;
const id = hookEventId({
eventName,
sessionId,
turnId,
transcriptPath,
cwd,
toolName,
toolUseId,
source,
});
return {
version: 1,
id,
eventName,
sessionId,
turnId,
cwd,
transcriptPath,
model,
source,
promptPreview: previewString(parsed.prompt),
toolName,
toolUseId,
toolInputPreview: previewJson(toolInput),
toolResponsePreview: previewJson(toolResponse),
permissionDescription: nullableString(record(toolInput).description),
lastAssistantMessage: eventName === "Stop" ? lastAssistantMessage : undefined,
stopHookActive: eventName === "Stop" ? stopHookActive : undefined,
createdAt: now().toISOString(),
};
}
function parseHookSpoolEvent(input: unknown): DiscordWorkspaceHookEvent {
const parsed = record(input);
if (parsed.version !== 1) {
throw new Error("Invalid hook event version");
}
const eventName = stringValue(parsed.eventName);
const id = stringValue(parsed.id);
const sessionId = stringValue(parsed.sessionId);
const createdAt = stringValue(parsed.createdAt);
if (!isHookEventName(eventName) || !id || !sessionId || !createdAt) {
throw new Error("Invalid hook event");
}
return {
version: 1,
id,
eventName,
sessionId,
turnId: stringValue(parsed.turnId),
cwd: stringValue(parsed.cwd),
transcriptPath: stringValue(parsed.transcriptPath),
model: stringValue(parsed.model),
source: stringValue(parsed.source),
promptPreview: stringValue(parsed.promptPreview),
toolName: stringValue(parsed.toolName),
toolUseId: stringValue(parsed.toolUseId),
toolInputPreview: stringValue(parsed.toolInputPreview),
toolResponsePreview: stringValue(parsed.toolResponsePreview),
permissionDescription: stringValue(parsed.permissionDescription),
lastAssistantMessage: nullableString(parsed.lastAssistantMessage),
stopHookActive: typeof parsed.stopHookActive === "boolean"
? parsed.stopHookActive
: undefined,
createdAt,
};
}
function hookEventId(input: {
eventName: DiscordWorkspaceHookEventName;
sessionId: string;
turnId?: string;
transcriptPath?: string;
cwd?: string;
toolName?: string;
toolUseId?: string;
source?: string;
}): string {
const identity = input.turnId
? {
eventName: input.eventName,
sessionId: input.sessionId,
turnId: input.turnId,
toolName: input.toolName,
toolUseId: input.toolUseId,
}
: {
eventName: input.eventName,
sessionId: input.sessionId,
source: input.source,
transcriptPath: input.transcriptPath,
cwd: input.cwd,
};
const prefix = input.eventName === "Stop" ? "stop" : "hook";
return `${prefix}-${createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 24)}`;
}
function isHookEventName(value: unknown): value is DiscordWorkspaceHookEventName {
return value === "SessionStart" ||
value === "UserPromptSubmit" ||
value === "PreToolUse" ||
value === "PermissionRequest" ||
value === "PostToolUse" ||
value === "Stop";
}
function previewString(value: unknown, maxLength = 500): string | undefined {
const parsed = nullableString(value);
if (!parsed) {
return undefined;
}
return parsed.length <= maxLength ? parsed : `${parsed.slice(0, maxLength - 3)}...`;
}
function previewJson(value: unknown, maxLength = 500): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
const text = typeof value === "string" ? value : JSON.stringify(value);
return previewString(text, maxLength);
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function nullableString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

View file

@ -1,4 +0,0 @@
#!/usr/bin/env node
import { runStopHook } from "./hook-cli.ts";
await runStopHook();

View file

@ -1,437 +0,0 @@
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;
workspace?: DiscordWorkspaceConfig;
cwd?: string;
model?: string;
modelProvider?: string;
serviceTier?: string;
effort?: ReasoningEffort;
summary?: ReasoningSummary;
approvalPolicy?: v2.AskForApproval;
sandbox?: v2.SandboxMode;
permissions?: v2.ThreadStartParams["permissions"];
typingIntervalMs?: number;
reconcileIntervalMs?: number;
hookSpoolDir?: string;
progressMode?: DiscordProgressMode;
consoleOutput?: DiscordConsoleOutputMode;
logLevel?: DiscordBridgeLogLevelSetting;
debug?: boolean;
};
export type DiscordProgressMode = "summary" | "commentary" | "none";
export type DiscordConsoleOutputMode = "messages" | "none";
export type DiscordWorkspaceConfig = {
homeChannelId: string;
mainThreadId?: string;
workspaceForumChannelId?: string;
taskThreadsChannelId?: string;
surfaces?: DiscordWorkspaceSurfaceConfig[];
};
export type DiscordWorkspaceSurfaceConfig = {
key: string;
homeChannelId: string;
workspaceForumChannelId?: string;
taskThreadsChannelId?: string;
workspaceCwds?: string[];
};
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 DiscordClearWebhooksInbound = {
kind: "clearWebhooks";
channelId: string;
guildId?: string;
author: DiscordAuthor;
webhookUrl?: string;
createdAt: string;
reply?: (text: string) => Promise<void>;
};
export type DiscordStatusInbound = {
kind: "status";
channelId: string;
guildId?: string;
author: DiscordAuthor;
createdAt: string;
reply?: (text: string) => Promise<void>;
replyPicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
};
export type DiscordThreadsInbound = {
kind: "threads";
channelId: string;
guildId?: string;
author: DiscordAuthor;
createdAt: string;
reply?: (text: string) => Promise<void>;
replyPicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
};
export type DiscordGoalsInbound = {
kind: "goals";
channelId: string;
guildId?: string;
author: DiscordAuthor;
createdAt: string;
objective?: string;
goalStatus?: v2.ThreadGoalStatus;
tokenBudget?: number;
clear?: boolean;
reply?: (text: string) => Promise<void>;
replyPicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
};
export type DiscordThreadPickerInbound = {
kind: "threadPicker";
channelId: string;
guildId?: string;
pickerId: string;
optionId: string;
author: DiscordAuthor;
createdAt: string;
reply?: (text: string) => Promise<void>;
update?: (text: string) => Promise<void>;
updatePicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
};
export type DiscordReactionInbound = {
kind: "reaction";
channelId: string;
guildId?: string;
messageId: string;
emoji: string;
author: DiscordAuthor;
createdAt: string;
};
export type DiscordInbound =
| DiscordMessageInbound
| DiscordThreadStartInbound
| DiscordClearInbound
| DiscordClearWebhooksInbound
| DiscordStatusInbound
| DiscordThreadsInbound
| DiscordGoalsInbound
| DiscordThreadPickerInbound
| DiscordReactionInbound;
export type DiscordEphemeralPicker = {
pickerId: string;
text: string;
options: DiscordEphemeralPickerOption[];
};
export type DiscordEphemeralPickerOption = {
id: string;
label: string;
};
export type DiscordBridgeTransportHandlers = {
onInbound(inbound: DiscordInbound): void;
};
export type DiscordBridgeCommandRegistration = {
channelIds?: string[];
};
export type DiscordBridgeTransport = {
start(handlers: DiscordBridgeTransportHandlers): Promise<void>;
stop(): Promise<void>;
registerCommands(options?: DiscordBridgeCommandRegistration): Promise<void>;
createForumPost?(
channelId: string,
name: string,
message: string,
): Promise<{ threadId: string; messageId?: string }>;
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>;
deleteWebhookMessages?(
channelId: string,
options?: { webhookUrl?: string },
): Promise<{ deleted: number; failed: number }>;
deleteThread?(channelId: string): Promise<void>;
addThreadMembers?(channelId: string, userIds: string[]): Promise<void>;
addReactions?(channelId: string, messageId: string, reactions: 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>;
injectThreadItems(params: v2.ThreadInjectItemsParams): Promise<v2.ThreadInjectItemsResponse>;
listThreads(params: v2.ThreadListParams): Promise<v2.ThreadListResponse>;
setThreadGoal(params: v2.ThreadGoalSetParams): Promise<v2.ThreadGoalSetResponse>;
getThreadGoal(params: v2.ThreadGoalGetParams): Promise<v2.ThreadGoalGetResponse>;
clearThreadGoal(params: v2.ThreadGoalClearParams): Promise<v2.ThreadGoalClearResponse>;
respond(id: string | number, result: unknown): void;
respondError(id: string | number, code: number, message: string, data?: unknown): void;
};
export type DiscordBridgeState = {
version: 1;
workspace?: DiscordWorkspaceState;
sessions: DiscordBridgeSession[];
queue: DiscordBridgeQueueItem[];
activeTurns: DiscordBridgeActiveTurn[];
processedMessageIds: string[];
deliveries: DiscordBridgeDelivery[];
};
export type DiscordWorkspaceState = {
homeChannelId: string;
mainThreadId?: string;
statusMessageId?: string;
createdAt?: string;
toolsVersion?: number;
delegations: DiscordWorkspaceDelegation[];
workspaces?: DiscordWorkspaceWorkspaceSurface[];
observedThreads?: DiscordWorkspaceObservedThread[];
pendingWakes?: DiscordWorkspacePendingWake[];
processedHookEventIds?: string[];
processedStopHookEventIds?: string[];
};
export type DiscordWorkspaceDelegationReturnMode =
| "detached"
| "record_only"
| "wake_on_done"
| "wake_on_group"
| "manual";
export type DiscordWorkspaceDelegation = {
id: string;
codexThreadId: string;
title: string;
status: "active" | "idle" | "failed" | "complete" | "reported";
cwd?: string;
workspaceKey?: string;
surfaceKey?: string;
groupId?: string;
returnMode?: DiscordWorkspaceDelegationReturnMode;
discordDetailThreadId?: string;
discordTaskThreadId?: string;
discordWorkspaceThreadId?: string;
parentDiscordMessageId?: string;
lastTurnId?: string;
lastStatus?: string;
lastFinal?: string;
completedAt?: string;
injectedAt?: string;
mirroredAt?: string;
taskMirroredAt?: string;
reportedAt?: string;
createdAt: string;
updatedAt: string;
};
export type DiscordWorkspaceWorkspaceSurface = {
key: string;
surfaceKey?: string;
cwd: string;
title: string;
discordThreadId: string;
statusMessageId?: string;
delegationIds: string[];
createdAt: string;
updatedAt: string;
};
export type DiscordWorkspacePendingWake = {
id: string;
kind: "delegation" | "group";
delegationIds: string[];
groupId?: string;
reason: string;
createdAt: string;
startedAt?: string;
};
export type DiscordWorkspaceHookEventName =
| "SessionStart"
| "UserPromptSubmit"
| "PreToolUse"
| "PermissionRequest"
| "PostToolUse"
| "Stop";
export type DiscordWorkspaceHookEvent = {
version: 1;
id: string;
eventName: DiscordWorkspaceHookEventName;
sessionId: string;
turnId?: string;
cwd?: string;
transcriptPath?: string;
model?: string;
source?: string;
promptPreview?: string;
toolName?: string;
toolUseId?: string;
toolInputPreview?: string;
toolResponsePreview?: string;
permissionDescription?: string;
lastAssistantMessage?: string;
stopHookActive?: boolean;
createdAt: string;
};
export type DiscordWorkspaceStopHookEvent = DiscordWorkspaceHookEvent & {
eventName: "Stop";
};
export type DiscordWorkspaceObservedThreadStatus =
| "starting"
| "active"
| "tool"
| "waiting"
| "idle";
export type DiscordWorkspaceObservedThread = {
threadId: string;
title?: string;
status: DiscordWorkspaceObservedThreadStatus;
cwd?: string;
workspaceKey?: string;
surfaceKey?: string;
model?: string;
transcriptPath?: string;
lastTurnId?: string;
lastHookEventName?: DiscordWorkspaceHookEventName;
source?: string;
promptPreview?: string;
assistantPreview?: string;
toolName?: string;
toolUseId?: string;
toolInputPreview?: string;
toolResponsePreview?: string;
permissionDescription?: string;
firstSeenAt: string;
lastSeenAt: string;
updatedAt: string;
};
export type DiscordBridgeSession = {
discordThreadId: string;
parentChannelId: string;
guildId?: string;
surfaceKey?: string;
sourceMessageId?: string;
codexThreadId: string;
title: string;
createdAt: string;
ownerUserId?: string;
participantUserIds?: string[];
cwd?: string;
mode?: "new" | "resumed" | "operator" | "delegated" | "workspace";
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>;
};

View file

@ -1,45 +0,0 @@
import type {
DiscordBridgeCommandRegistration,
DiscordBridgeState,
DiscordInbound,
} from "./types.ts";
export type CodexWorkspaceBackend = {
start(): Promise<void>;
startTransportDependentWork?(): Promise<void>;
startBackgroundWork?(): Promise<void>;
stop(): Promise<void>;
handleInbound(inbound: DiscordInbound): Promise<void>;
commandRegistration(): DiscordBridgeCommandRegistration;
stateForTest?(): DiscordBridgeState;
flushSummariesForTest?(): Promise<void>;
};
export type CodexWorkspacePresenter = {
createWorkspacePost?(
locationId: string,
title: string,
body: string,
): Promise<{ threadId: string; messageId?: string }>;
createThread(
locationId: string,
title: string,
sourceMessageId?: string,
): Promise<string>;
sendMessage(locationId: string, text: string): Promise<string[]>;
updateMessage?(
locationId: string,
messageId: string,
text: string,
): Promise<void>;
deleteMessage(locationId: string, messageId: string): Promise<void>;
deleteWebhookMessages?(
locationId: string,
options?: { webhookUrl?: string },
): Promise<{ deleted: number; failed: number }>;
deleteThread?(locationId: string): Promise<void>;
addThreadMembers?(threadId: string, userIds: string[]): Promise<void>;
addReactions?(locationId: string, messageId: string, reactions: string[]): Promise<void>;
pinMessage?(locationId: string, messageId: string): Promise<void>;
sendTyping(locationId: string): Promise<void>;
};

File diff suppressed because it is too large Load diff

View file

@ -1,499 +0,0 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vite-plus/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("parses workspace home and main thread ids", () => {
const fromFlag = parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--home-channel-id",
"home-channel",
"--main-thread-id",
"main-thread",
"--workspace-forum-channel-id",
"workspace-forum",
"--task-threads-channel-id",
"task-channel",
],
{},
);
const fromEnv = parseConfig(
["--token", "discord-token", "--allowed-user-ids", "user-1"],
{
CODEX_DISCORD_GATEWAY_HOME_CHANNEL_ID: "env-home",
CODEX_DISCORD_GATEWAY_MAIN_THREAD_ID: "env-thread",
CODEX_DISCORD_GATEWAY_WORKSPACE_FORUM_CHANNEL_ID: "env-workspace-forum",
CODEX_DISCORD_GATEWAY_TASK_THREADS_CHANNEL_ID: "env-task-channel",
},
);
expect(fromFlag.type).toBe("run");
expect(fromEnv.type).toBe("run");
if (fromFlag.type === "run" && fromEnv.type === "run") {
expect(fromFlag.config.workspace).toEqual({
homeChannelId: "home-channel",
mainThreadId: "main-thread",
workspaceForumChannelId: "workspace-forum",
taskThreadsChannelId: "task-channel",
});
expect(fromEnv.config.workspace).toEqual({
homeChannelId: "env-home",
mainThreadId: "env-thread",
workspaceForumChannelId: "env-workspace-forum",
taskThreadsChannelId: "env-task-channel",
});
}
});
test("parses workspace-owned workspace surfaces and keeps env defaults as fallback", () => {
const root = workspaceRoot();
writeWorkspaceToml(root, "crypto-workspace", `
[[discord.workspace.surfaces]]
key = "crypto"
home_channel_id = "home-b"
workspace_forum_channel_id = "forum-b"
task_threads_channel_id = "tasks-b"
`);
writeWorkspaceToml(root, "research-workspace", `
[[discord.workspace.surfaces]]
key = "crypto"
home_channel_id = "home-b"
workspace_forum_channel_id = "forum-b"
task_threads_channel_id = "tasks-b"
`);
try {
const parsed = parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--dir",
root,
],
{
CODEX_DISCORD_HOME_CHANNEL_ID: "home-a",
CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID: "forum-a",
CODEX_DISCORD_TASK_THREADS_CHANNEL_ID: "tasks-a",
},
);
expect(parsed.type).toBe("run");
if (parsed.type === "run") {
expect(parsed.config.workspace).toEqual({
homeChannelId: "home-a",
workspaceForumChannelId: "forum-a",
taskThreadsChannelId: "tasks-a",
surfaces: [
{
key: "default",
homeChannelId: "home-a",
workspaceForumChannelId: "forum-a",
taskThreadsChannelId: "tasks-a",
workspaceCwds: undefined,
},
{
key: "crypto",
homeChannelId: "home-b",
workspaceForumChannelId: "forum-b",
taskThreadsChannelId: "tasks-b",
workspaceCwds: [
path.join(root, "crypto-workspace"),
path.join(root, "research-workspace"),
],
},
],
});
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("rejects ambiguous workspace-owned workspace surfaces", () => {
const multiple = workspaceRoot();
writeWorkspaceToml(multiple, "crypto-workspace", `
[[discord.workspace.surfaces]]
key = "default"
home_channel_id = "home-a"
[[discord.workspace.surfaces]]
key = "other"
home_channel_id = "home-b"
`);
try {
expect(() => parseConfig(baseArgsForRoot(multiple), {})).toThrow(
"workspace.toml discord.workspace.surfaces must contain one surface",
);
} finally {
rmSync(multiple, { recursive: true, force: true });
}
const duplicate = workspaceRoot();
writeWorkspaceToml(duplicate, "crypto-workspace", `
[[discord.workspace.surfaces]]
key = "default"
home_channel_id = "home-a"
`);
writeWorkspaceToml(duplicate, "research-workspace", `
[[discord.workspace.surfaces]]
key = "default"
home_channel_id = "home-b"
`);
try {
expect(() => parseConfig(baseArgsForRoot(duplicate), {})).toThrow(
"Workspace surface key default is configured with different channels.",
);
} finally {
rmSync(duplicate, { recursive: true, force: true });
}
const channelCollision = workspaceRoot();
writeWorkspaceToml(channelCollision, "crypto-workspace", `
[[discord.workspace.surfaces]]
key = "crypto"
home_channel_id = "home-a"
`);
writeWorkspaceToml(channelCollision, "alpha-workspace", `
[[discord.workspace.surfaces]]
key = "alpha"
home_channel_id = "home-a"
`);
try {
expect(() => parseConfig(baseArgsForRoot(channelCollision), {})).toThrow(
"Workspace surface channel is configured more than once: home-a",
);
} finally {
rmSync(channelCollision, { recursive: true, force: true });
}
});
test("ignores workspace.toml without workspace surfaces", () => {
const root = workspaceRoot();
writeRootWorkspaceToml(root, `
name = "home"
[tools]
enabled = true
`);
try {
const parsed = parseConfig(baseArgsForRoot(root), {});
expect(parsed.type).toBe("run");
if (parsed.type === "run") {
expect(parsed.config.workspace).toBeUndefined();
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("rejects workspace main thread without home channel", () => {
expect(() =>
parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--main-thread-id",
"main-thread",
],
{},
)
).toThrow("Cannot set a workspace main thread without a workspace home channel.");
});
test("rejects partial workspace workbench channel configuration", () => {
expect(() =>
parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--home-channel-id",
"home-channel",
"--workspace-forum-channel-id",
"workspace-forum",
],
{},
)
).toThrow(
"Discord workbench requires both workspace forum and task threads channels.",
);
});
test("rejects workspace workbench channels that are not separate", () => {
expect(() =>
parseConfig(
[
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--home-channel-id",
"home-channel",
"--workspace-forum-channel-id",
"workspace-forum",
"--task-threads-channel-id",
"home-channel",
],
{},
)
).toThrow(
"Discord workbench channels must be separate from the workspace home channel and each other.",
);
});
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.");
});
});
function workspaceRoot(): string {
return mkdtempSync(path.join(os.tmpdir(), "discord-workspace-config-"));
}
function writeRootWorkspaceToml(root: string, toml: string): void {
const codexDir = path.join(root, ".codex");
mkdirSync(codexDir, { recursive: true });
writeFileSync(path.join(codexDir, "workspace.toml"), toml);
}
function writeWorkspaceToml(root: string, workspaceName: string, toml: string): void {
const workspaceDir = path.join(root, workspaceName);
const codexDir = path.join(workspaceDir, ".codex");
mkdirSync(codexDir, { recursive: true });
writeFileSync(path.join(codexDir, "workspace.toml"), toml);
}
function baseArgsForRoot(root: string): string[] {
return [
"--token",
"discord-token",
"--allowed-user-ids",
"user-1",
"--dir",
root,
];
}

View file

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

View file

@ -1,183 +0,0 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vite-plus/test";
import {
enableHooksFeature,
installStopHook,
upsertStopHookConfig,
} from "../src/hook-cli.ts";
describe("discord workspace hook CLI", () => {
test("enables the current hooks feature in config.toml", () => {
expect(enableHooksFeature("model = \"gpt-5\"\n")).toBe(
"model = \"gpt-5\"\n\n[features]\nhooks = true\n",
);
expect(enableHooksFeature("[features]\ngoals = true\n")).toBe(
"[features]\nhooks = true\ngoals = true\n",
);
expect(enableHooksFeature("[features]\nhooks = false\ngoals = true\n")).toBe(
"[features]\nhooks = true\ngoals = true\n",
);
});
test("upserts package-bin observability hooks while preserving unrelated hooks", () => {
const updated = upsertStopHookConfig(
JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: "Bash",
hooks: [{ type: "command", command: "echo pre" }],
},
],
Stop: [
{
hooks: [
{
type: "command",
command:
"node /home/peezy/codex-fork-workspace/codex-flows/apps/discord-bridge/dist/index.js hook event",
},
{ type: "command", command: "echo other-stop" },
],
},
],
},
}),
"codex-discord-bridge hook event",
);
expect(updated).toEqual({
hooks: {
PreToolUse: [
{
hooks: [
{
type: "command",
command: "codex-discord-bridge hook event",
timeout: 10,
},
],
},
{
matcher: "Bash",
hooks: [{ type: "command", command: "echo pre" }],
},
],
PermissionRequest: [
{
hooks: [
{
type: "command",
command: "codex-discord-bridge hook event",
timeout: 10,
},
],
},
],
PostToolUse: [
{
hooks: [
{
type: "command",
command: "codex-discord-bridge hook event",
timeout: 10,
},
],
},
],
SessionStart: [
{
hooks: [
{
type: "command",
command: "codex-discord-bridge hook event",
timeout: 10,
},
],
},
],
Stop: [
{
hooks: [
{
type: "command",
command: "codex-discord-bridge hook event",
timeout: 10,
},
],
},
{
hooks: [{ type: "command", command: "echo other-stop" }],
},
],
UserPromptSubmit: [
{
hooks: [
{
type: "command",
command: "codex-discord-bridge hook event",
timeout: 10,
},
],
},
],
},
});
});
test("install writes config and hooks files", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "discord-hook-cli-"));
try {
const configPath = path.join(dir, "config.toml");
const hooksPath = path.join(dir, "hooks.json");
await writeFile(configPath, "[features]\ngoals = true\n");
const result = await installStopHook({
configPath,
hooksPath,
useDlx: true,
});
expect(result).toEqual({
command:
"vp dlx @peezy.tech/codex-discord-bridge codex-discord-bridge hook event",
configPath,
hooksPath,
dryRun: false,
});
expect(await readFile(configPath, "utf8")).toBe(
"[features]\nhooks = true\ngoals = true\n",
);
expect(JSON.parse(await readFile(hooksPath, "utf8"))).toEqual(
expect.objectContaining({
hooks: expect.objectContaining({
UserPromptSubmit: [
{
hooks: [
expect.objectContaining({
command:
"vp dlx @peezy.tech/codex-discord-bridge codex-discord-bridge hook event",
}),
],
},
],
Stop: [
{
hooks: [
expect.objectContaining({
command:
"vp dlx @peezy.tech/codex-discord-bridge codex-discord-bridge hook event",
}),
],
},
],
}),
}),
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

View file

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

View file

@ -1,254 +0,0 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vite-plus/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,
workspace: {
homeChannelId: "home-channel",
mainThreadId: "codex-workspace-thread",
statusMessageId: "message-workspace-status",
createdAt: "2026-05-11T00:00:00.000Z",
toolsVersion: 1,
delegations: [
{
id: "delegation-1",
codexThreadId: "codex-delegated-thread",
title: "Patchbay webhook work",
status: "active",
cwd: "/workspace/patchbay",
workspaceKey: "workspace-patchbay",
surfaceKey: "org-a",
discordDetailThreadId: "discord-detail-thread",
discordTaskThreadId: "discord-task-thread",
discordWorkspaceThreadId: "discord-workspace-thread",
parentDiscordMessageId: "message-parent",
taskMirroredAt: "2026-05-11T00:00:02.500Z",
createdAt: "2026-05-11T00:00:01.000Z",
updatedAt: "2026-05-11T00:00:02.000Z",
},
],
workspaces: [
{
key: "workspace-patchbay",
surfaceKey: "org-a",
cwd: "/workspace/patchbay",
title: "patchbay",
discordThreadId: "discord-workspace-thread",
statusMessageId: "message-workspace-status",
delegationIds: ["delegation-1", "", "delegation-1"],
createdAt: "2026-05-11T00:00:00.500Z",
updatedAt: "2026-05-11T00:00:02.500Z",
},
],
observedThreads: [
{
threadId: "codex-observed-thread",
title: "Observed work",
status: "waiting",
cwd: "/workspace/patchbay",
workspaceKey: "workspace-patchbay",
surfaceKey: "org-a",
model: "gpt-test",
transcriptPath: "/tmp/observed.jsonl",
lastTurnId: "turn-observed",
lastHookEventName: "PermissionRequest",
promptPreview: "Observed prompt",
permissionDescription: "Needs approval",
firstSeenAt: "2026-05-11T00:00:01.000Z",
lastSeenAt: "2026-05-11T00:00:04.000Z",
updatedAt: "2026-05-11T00:00:04.000Z",
},
],
pendingWakes: [
{
id: "wake-1",
kind: "group",
delegationIds: ["delegation-1"],
groupId: "patchbay",
reason: "Group patchbay completed.",
createdAt: "2026-05-11T00:00:03.000Z",
},
],
processedStopHookEventIds: [
"stop-1",
"",
"stop-1",
"stop-2",
],
processedHookEventIds: ["hook-1", "", "hook-1"],
},
sessions: [
{
discordThreadId: "discord-thread-1",
parentChannelId: "parent-channel",
surfaceKey: "org-a",
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: "workspace",
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.workspace).toEqual({
homeChannelId: "home-channel",
mainThreadId: "codex-workspace-thread",
statusMessageId: "message-workspace-status",
createdAt: "2026-05-11T00:00:00.000Z",
toolsVersion: 1,
delegations: [
{
id: "delegation-1",
codexThreadId: "codex-delegated-thread",
title: "Patchbay webhook work",
status: "active",
cwd: "/workspace/patchbay",
workspaceKey: "workspace-patchbay",
surfaceKey: "org-a",
discordDetailThreadId: "discord-detail-thread",
discordTaskThreadId: "discord-task-thread",
discordWorkspaceThreadId: "discord-workspace-thread",
parentDiscordMessageId: "message-parent",
taskMirroredAt: "2026-05-11T00:00:02.500Z",
createdAt: "2026-05-11T00:00:01.000Z",
updatedAt: "2026-05-11T00:00:02.000Z",
},
],
workspaces: [
{
key: "workspace-patchbay",
surfaceKey: "org-a",
cwd: "/workspace/patchbay",
title: "patchbay",
discordThreadId: "discord-workspace-thread",
statusMessageId: "message-workspace-status",
delegationIds: ["delegation-1"],
createdAt: "2026-05-11T00:00:00.500Z",
updatedAt: "2026-05-11T00:00:02.500Z",
},
],
observedThreads: [
{
threadId: "codex-observed-thread",
title: "Observed work",
status: "waiting",
cwd: "/workspace/patchbay",
workspaceKey: "workspace-patchbay",
surfaceKey: "org-a",
model: "gpt-test",
transcriptPath: "/tmp/observed.jsonl",
lastTurnId: "turn-observed",
lastHookEventName: "PermissionRequest",
source: undefined,
promptPreview: "Observed prompt",
assistantPreview: undefined,
toolName: undefined,
toolUseId: undefined,
toolInputPreview: undefined,
toolResponsePreview: undefined,
permissionDescription: "Needs approval",
firstSeenAt: "2026-05-11T00:00:01.000Z",
lastSeenAt: "2026-05-11T00:00:04.000Z",
updatedAt: "2026-05-11T00:00:04.000Z",
},
],
pendingWakes: [
{
id: "wake-1",
kind: "group",
delegationIds: ["delegation-1"],
groupId: "patchbay",
reason: "Group patchbay completed.",
createdAt: "2026-05-11T00:00:03.000Z",
},
],
processedHookEventIds: ["hook-1", "stop-1", "stop-2"],
processedStopHookEventIds: ["stop-1", "stop-2"],
});
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]?.surfaceKey).toBe("org-a");
expect(state.sessions[0]?.participantUserIds).toEqual([
"user-2",
"user-3",
]);
expect(state.sessions[0]?.cwd).toBe("/workspace/project");
expect(state.sessions[0]?.mode).toBe("workspace");
expect(state.sessions[0]?.statusMessageId).toBe("message-status-1");
expect(state.sessions[1]?.ownerUserId).toBeUndefined();
expect(state.sessions[1]?.sourceMessageId).toBeUndefined();
expect(state.sessions[1]?.participantUserIds).toBeUndefined();
expect(state.sessions[1]?.cwd).toBeUndefined();
expect(state.sessions[1]?.mode).toBeUndefined();
expect(state.sessions[1]?.statusMessageId).toBeUndefined();
expect(state.activeTurns).toEqual([
{
turnId: "turn-active-1",
discordThreadId: "discord-thread-1",
codexThreadId: "codex-thread-1",
origin: "external",
startedAt: "2026-05-11T00:00:01.000Z",
observedAt: "2026-05-11T00:00:02.000Z",
},
{
turnId: "turn-active-2",
discordThreadId: "discord-thread-2",
codexThreadId: "codex-thread-2",
origin: "external",
queueItemId: "queue-1",
observedAt: "2026-05-11T00:00:03.000Z",
},
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

View file

@ -1,129 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vite-plus/test";
import {
archiveStopHookSpoolFile,
readPendingStopHookSpoolFiles,
writeStopHookSpoolEvent,
} from "../src/stop-hook-spool.ts";
describe("stop hook spool", () => {
test("writes stable Stop events into the pending spool", async () => {
const spoolDir = await mkdtemp(path.join(os.tmpdir(), "stop-hook-spool-"));
try {
const input = {
hook_event_name: "Stop",
session_id: "session-1",
turn_id: "turn-1",
cwd: "/workspace",
transcript_path: "/tmp/session.jsonl",
last_assistant_message: "Finished.",
stop_hook_active: false,
};
const first = await writeStopHookSpoolEvent(input, {
spoolDir,
now: () => new Date("2026-05-14T12:00:00.000Z"),
});
const second = await writeStopHookSpoolEvent(input, {
spoolDir,
now: () => new Date("2026-05-14T12:01:00.000Z"),
});
const third = await writeStopHookSpoolEvent(
{ ...input, last_assistant_message: "Finished again." },
{
spoolDir,
now: () => new Date("2026-05-14T12:02:00.000Z"),
},
);
expect(second.id).toBe(first.id);
expect(third.id).toBe(first.id);
const pending = await readPendingStopHookSpoolFiles(spoolDir);
expect(pending).toHaveLength(1);
expect(pending[0]).toEqual(
expect.objectContaining({
event: expect.objectContaining({
id: first.id,
eventName: "Stop",
sessionId: "session-1",
turnId: "turn-1",
lastAssistantMessage: "Finished again.",
stopHookActive: false,
}),
}),
);
} finally {
await rm(spoolDir, { recursive: true, force: true });
}
});
test("writes passive lifecycle hook events with previews", async () => {
const spoolDir = await mkdtemp(path.join(os.tmpdir(), "hook-spool-"));
try {
const event = await writeStopHookSpoolEvent(
{
hook_event_name: "UserPromptSubmit",
session_id: "session-observed",
turn_id: "turn-observed",
cwd: "/workspace/observed",
transcript_path: "/tmp/session-observed.jsonl",
model: "gpt-test",
prompt: "Inspect the observed workspace without routing through Discord.",
},
{
spoolDir,
now: () => new Date("2026-05-14T12:00:00.000Z"),
},
);
expect(event).toEqual(
expect.objectContaining({
eventName: "UserPromptSubmit",
sessionId: "session-observed",
turnId: "turn-observed",
cwd: "/workspace/observed",
model: "gpt-test",
promptPreview:
"Inspect the observed workspace without routing through Discord.",
}),
);
const pending = await readPendingStopHookSpoolFiles(spoolDir);
expect(pending[0]).toEqual(
expect.objectContaining({
event: expect.objectContaining({
id: event.id,
eventName: "UserPromptSubmit",
promptPreview:
"Inspect the observed workspace without routing through Discord.",
}),
}),
);
} finally {
await rm(spoolDir, { recursive: true, force: true });
}
});
test("archives processed files out of pending", async () => {
const spoolDir = await mkdtemp(path.join(os.tmpdir(), "stop-hook-spool-"));
try {
await writeStopHookSpoolEvent(
{
hook_event_name: "Stop",
session_id: "session-1",
turn_id: "turn-1",
},
{ spoolDir },
);
const [file] = await readPendingStopHookSpoolFiles(spoolDir);
expect(file).toBeDefined();
await archiveStopHookSpoolFile(file!, spoolDir, "processed");
expect(await readPendingStopHookSpoolFiles(spoolDir)).toEqual([]);
} finally {
await rm(spoolDir, { recursive: true, force: true });
}
});
});

View file

@ -1,29 +0,0 @@
{
"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"],
"baseUrl": ".",
"paths": {
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/browser": ["../../packages/codex-client/src/browser.ts"],
"@peezy.tech/codex-flows/generated": ["../../packages/codex-client/src/app-server/generated/index.ts"],
"@peezy.tech/codex-flows/generated/*": ["../../packages/codex-client/src/app-server/generated/*"],
"@peezy.tech/codex-flows/rpc": ["../../packages/codex-client/src/app-server/rpc.ts"],
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"]
}
},
"include": ["src", "test"]
}

View file

@ -1,23 +0,0 @@
# @peezy.tech/codex-workspace-voice-gateway
Broadcast-only Discord voice gateway for Codex workspace backend updates.
```bash
pnpm add @peezy.tech/codex-workspace-voice-gateway
```
Run it beside a workspace backend and a TTS worker:
```bash
codex-workspace-backend-local serve --local-app-server
codex-workspace-voice-gateway \
--workspace-backend-url ws://127.0.0.1:3586 \
--tts-worker-url http://127.0.0.1:8000
```
This package is a channel-specific gateway. It depends on
`@peezy.tech/codex-flows` for the workspace backend protocol and keeps Discord
voice/TTS dependencies outside the core package.
Full reference docs live in
`docs/pages/reference/workspace-voice-gateway.md` in the source repository.

View file

@ -1,49 +0,0 @@
{
"name": "@peezy.tech/codex-workspace-voice-gateway",
"version": "0.132.6",
"description": "Broadcast-only Discord voice gateway for Codex workspace backend updates.",
"type": "module",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/peezy-tech/codex-flows.git",
"directory": "apps/workspace-voice-gateway"
},
"keywords": [
"codex",
"discord",
"voice",
"gateway"
],
"bin": {
"codex-workspace-voice-gateway": "dist/index.js"
},
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "vp run clean && tsx scripts/build-package.ts",
"check:types": "tsc --noEmit",
"clean": "rm -rf dist",
"pack:dry-run": "npm pack --dry-run --json",
"prepack": "vp run build",
"release:check": "vp run test && vp run check:types && vp run build && vp run smoke:bin && vp run pack:dry-run",
"smoke:bin": "tsx scripts/smoke-bin.ts",
"start": "tsx ./src/index.ts",
"test": "vp test run --root ../.. apps/workspace-voice-gateway/test"
},
"dependencies": {
"@discordjs/voice": "^0.19.2",
"@peezy.tech/codex-flows": "workspace:*",
"discord.js": "^14.22.1",
"opusscript": "^0.1.1"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

View file

@ -1,87 +0,0 @@
import { spawn } from "node:child_process";
import { chmod, mkdir, readdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "..");
const outDir = path.join(appRoot, "dist");
const packOutDir = path.join(outDir, ".pack");
const outfile = path.join(outDir, "index.js");
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
const proc = spawn("vp", [
"pack",
"src/index.ts",
"--platform=node",
"--format=esm",
"--target=node24",
"--out-dir",
packOutDir,
"--deps.never-bundle",
"@peezy.tech/codex-flows",
"--deps.never-bundle",
"@peezy.tech/codex-flows/*",
"--deps.never-bundle",
"@discordjs/*",
"--deps.never-bundle",
"discord.js",
"--deps.never-bundle",
"ffmpeg-static",
"--deps.never-bundle",
"opusscript",
], {
cwd: appRoot,
});
const [stdout, stderr, exitCode] = await Promise.all([
collectText(proc.stdout),
collectText(proc.stderr),
exitCodeFor(proc),
]);
if (exitCode !== 0) {
process.stderr.write(stderr);
process.stderr.write(stdout);
process.exit(exitCode);
}
await movePackOutput(packOutDir, outfile);
await rm(packOutDir, { recursive: true, force: true });
await chmod(outfile, 0o755);
process.stderr.write(`built ${path.relative(appRoot, outfile)}\n`);
async function movePackOutput(packDir: string, entryOutfile: string): Promise<void> {
await rename(path.join(packDir, "index.mjs"), entryOutfile);
for (const entry of await readdir(packDir, { withFileTypes: true })) {
if (!entry.isFile()) {
continue;
}
await rename(path.join(packDir, entry.name), path.join(outDir, entry.name));
}
}
function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
return new Promise((resolve, reject) => {
let output = "";
if (!stream) {
resolve(output);
return;
}
stream.setEncoding("utf8");
stream.on("data", (chunk: string) => {
output += chunk;
});
stream.once("error", reject);
stream.once("end", () => resolve(output));
});
}
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => resolve(code));
});
}

View file

@ -1,56 +0,0 @@
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "..");
const binPath = path.join(appRoot, "dist", "index.js");
await access(binPath);
const proc = spawn(process.execPath, [binPath, "--help"], {
cwd: appRoot,
});
const [stdout, stderr, exitCode] = await Promise.all([
collectText(proc.stdout),
collectText(proc.stderr),
exitCodeFor(proc),
]);
if (exitCode !== 0) {
process.stderr.write(stderr);
process.stderr.write(stdout);
process.exit(exitCode);
}
if (!stdout.includes("codex-workspace-voice-gateway")) {
throw new Error(
"codex-workspace-voice-gateway --help did not mention codex-workspace-voice-gateway",
);
}
console.log("bin smoke test passed");
function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
return new Promise((resolve, reject) => {
let output = "";
if (!stream) {
resolve(output);
return;
}
stream.setEncoding("utf8");
stream.on("data", (chunk: string) => {
output += chunk;
});
stream.once("error", reject);
stream.once("end", () => resolve(output));
});
}
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => resolve(code));
});
}

View file

@ -1,273 +0,0 @@
import type { v2 } from "@peezy.tech/codex-flows/generated";
import type { JsonRpcNotification } from "@peezy.tech/codex-flows/rpc";
import type { WorkspaceBackendEvent } from "@peezy.tech/codex-flows/workspace-backend";
import { cleanForSpeech, record, stringValue } from "./text.ts";
import type { AnnouncementPriority, VoiceAnnouncement } from "./types.ts";
export type AnnouncementPolicy = {
announceBackendConnected: boolean;
announceTurnStarted: boolean;
ignoredThreadIds?: ReadonlySet<string>;
};
export type TurnCompletionContext = {
threadId: string;
turnId: string;
status: v2.TurnStatus;
durationMs: number | null;
finalText: string;
errorMessage: string | null;
};
export type AnnouncementDraft = VoiceAnnouncement & {
kind:
| "backend.connected"
| "backend.error"
| "backend.closed"
| "turn.started"
| "turn.completed"
| "hook.completed"
| "warning"
| "error";
turnCompletion?: TurnCompletionContext;
};
export function draftFromWorkspaceEvent(
event: WorkspaceBackendEvent,
policy: AnnouncementPolicy,
): AnnouncementDraft | undefined {
if (event.type === "connected") {
if (!policy.announceBackendConnected) {
return undefined;
}
return draft({
id: `backend:connected:${event.at}`,
kind: "backend.connected",
source: "workspace-backend",
priority: "low",
text: "Workspace backend connected.",
policy,
});
}
if (event.type === "appServer.connected") {
return draft({
id: `app-server:connected:${event.at}`,
kind: "backend.connected",
source: "workspace-backend",
priority: "low",
text: "Codex app server connected.",
policy,
});
}
if (event.type === "appServer.closed") {
const reason = event.reason ? ` Reason: ${event.reason}.` : "";
return draft({
id: `app-server:closed:${event.at}`,
kind: "backend.closed",
source: "workspace-backend",
priority: "high",
text: `Codex app server disconnected.${reason}`,
policy,
});
}
if (event.type === "appServer.error") {
return draft({
id: `app-server:error:${event.at}:${event.message}`,
kind: "backend.error",
source: "workspace-backend",
priority: "high",
text: `Codex app server error: ${event.message}`,
policy,
});
}
if (event.type === "unsupportedWorkspaceBackendMethod") {
return undefined;
}
return undefined;
}
export function draftFromNotification(
message: JsonRpcNotification,
policy: AnnouncementPolicy,
): AnnouncementDraft | undefined {
const params = record(message.params);
const threadId = stringValue(params.threadId);
if (threadId && policy.ignoredThreadIds?.has(threadId)) {
return undefined;
}
if (message.method === "turn/started") {
if (!policy.announceTurnStarted) {
return undefined;
}
const turn = record(params.turn);
const turnId = stringValue(turn.id);
if (!threadId || !turnId) {
return undefined;
}
return draft({
id: `turn:started:${threadId}:${turnId}`,
kind: "turn.started",
source: "app-server",
priority: "low",
text: "Workspace turn started.",
policy,
});
}
if (message.method === "turn/completed") {
const context = turnCompletionContext(message);
if (!context) {
return undefined;
}
const statusText = context.status === "completed"
? "Workspace turn completed."
: `Workspace turn ${context.status}.`;
const detail = context.errorMessage ?? context.finalText;
const text = detail ? `${statusText} ${detail}` : statusText;
return {
...draft({
id: `turn:completed:${context.threadId}:${context.turnId}:${context.status}`,
kind: "turn.completed",
source: "app-server",
priority: context.status === "completed" ? "normal" : "high",
text,
policy,
}),
turnCompletion: context,
};
}
if (message.method === "hook/completed") {
const run = record(params.run);
const status = stringValue(run.status);
if (!threadId || !status || status === "completed") {
return undefined;
}
const eventName = stringValue(run.eventName) ?? "hook";
const statusMessage = stringValue(run.statusMessage);
return draft({
id: `hook:${threadId}:${stringValue(run.id) ?? eventName}:${status}`,
kind: "hook.completed",
source: "app-server",
priority: "high",
text: `Codex ${eventName} hook ${status}.${statusMessage ? ` ${statusMessage}` : ""}`,
policy,
});
}
if (message.method === "error") {
const error = record(params.error);
const messageText = stringValue(error.message) ?? "Unknown error.";
const turnId = stringValue(params.turnId) ?? "unknown";
return draft({
id: `error:${threadId ?? "global"}:${turnId}:${messageText}`,
kind: "error",
source: "app-server",
priority: "high",
text: `Codex turn error: ${messageText}`,
policy,
});
}
if (
message.method === "warning" ||
message.method === "configWarning" ||
message.method === "guardianWarning"
) {
const messageText = stringValue(params.message);
if (!messageText) {
return undefined;
}
return draft({
id: `warning:${threadId ?? "global"}:${message.method}:${messageText}`,
kind: "warning",
source: "app-server",
priority: "normal",
text: messageText,
policy,
});
}
return undefined;
}
export function turnCompletionContext(
message: JsonRpcNotification,
): TurnCompletionContext | undefined {
if (message.method !== "turn/completed") {
return undefined;
}
const params = record(message.params);
const threadId = stringValue(params.threadId);
const turn = record(params.turn);
const turnId = stringValue(turn.id);
const status = turnStatusValue(turn.status);
if (!threadId || !turnId || !status) {
return undefined;
}
return {
threadId,
turnId,
status,
durationMs: numberValue(turn.durationMs),
finalText: finalTextFromTurn(turn),
errorMessage: turnErrorMessage(turn),
};
}
export function finalTextFromTurn(turn: Partial<v2.Turn> | Record<string, unknown>): string {
const items = Array.isArray(turn.items) ? turn.items : [];
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = record(items[index]);
if (item.type !== "agentMessage") {
continue;
}
if (item.phase === "commentary") {
continue;
}
const text = stringValue(item.text);
if (text) {
return text;
}
}
return "";
}
function draft(input: {
id: string;
kind: AnnouncementDraft["kind"];
source: string;
priority: AnnouncementPriority;
text: string;
policy: AnnouncementPolicy;
}): AnnouncementDraft {
return {
id: input.id,
kind: input.kind,
source: input.source,
priority: input.priority,
text: cleanForSpeech(input.text),
};
}
function turnStatusValue(value: unknown): v2.TurnStatus | undefined {
if (
value === "completed" ||
value === "interrupted" ||
value === "failed" ||
value === "inProgress"
) {
return value;
}
return undefined;
}
function turnErrorMessage(turn: Record<string, unknown>): string | null {
const error = record(turn.error);
return stringValue(error.message) ?? null;
}
function numberValue(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}

View file

@ -1,280 +0,0 @@
import type { ReasoningEffort, v2 } from "@peezy.tech/codex-flows/generated";
import {
CodexWorkspaceBackendClient,
type CodexWorkspaceBackendClientOptions,
} from "@peezy.tech/codex-flows/workspace-backend";
import {
finalTextFromTurn,
type TurnCompletionContext,
} from "./announcements.ts";
import { cleanForSpeech, errorMessage, record, stringValue } from "./text.ts";
import type { AnnouncementPriority, Logger } from "./types.ts";
export type AnnouncerDecision = {
speak: boolean;
text: string;
priority: AnnouncementPriority;
};
export type TurnAnnouncer = {
polish(context: TurnCompletionContext): Promise<AnnouncerDecision>;
ignoredThreadIds?(): Iterable<string>;
close?(): void;
};
export type CodexTurnAnnouncerOptions = {
workspaceBackendUrl: string;
model: string;
reasoningEffort: ReasoningEffort;
timeoutMs: number;
maxPhraseChars: number;
cwd?: string | null;
logger: Logger;
clientOptions?: Partial<CodexWorkspaceBackendClientOptions>;
};
const announcerInstructions = [
"You turn Codex workspace turn-completion data into one concise spoken announcement.",
"Return only JSON matching the provided schema.",
"Do not use markdown, bullets, code fences, URLs, stack traces, raw ids, or raw command logs.",
"Prefer the practical outcome and next risk over implementation detail.",
"Set speak=false when the turn has no meaningful user-facing update.",
].join("\n");
export class TemplateTurnAnnouncer implements TurnAnnouncer {
async polish(context: TurnCompletionContext): Promise<AnnouncerDecision> {
const prefix = context.status === "completed"
? "Workspace turn completed."
: `Workspace turn ${context.status}.`;
const detail = context.errorMessage ?? context.finalText;
return {
speak: Boolean(detail || context.status !== "completed"),
priority: context.status === "completed" ? "normal" : "high",
text: cleanForSpeech(detail ? `${prefix} ${detail}` : prefix),
};
}
}
export class CodexTurnAnnouncer implements TurnAnnouncer {
#workspaceBackendUrl: string;
#model: string;
#reasoningEffort: ReasoningEffort;
#timeoutMs: number;
#maxPhraseChars: number;
#cwd: string | null;
#logger: Logger;
#clientOptions: Partial<CodexWorkspaceBackendClientOptions>;
#client?: CodexWorkspaceBackendClient;
#threadId?: string;
constructor(options: CodexTurnAnnouncerOptions) {
this.#workspaceBackendUrl = options.workspaceBackendUrl;
this.#model = options.model;
this.#reasoningEffort = options.reasoningEffort;
this.#timeoutMs = options.timeoutMs;
this.#maxPhraseChars = options.maxPhraseChars;
this.#cwd = options.cwd ?? null;
this.#logger = options.logger;
this.#clientOptions = options.clientOptions ?? {};
}
ignoredThreadIds(): Iterable<string> {
return this.#threadId ? [this.#threadId] : [];
}
async polish(context: TurnCompletionContext): Promise<AnnouncerDecision> {
const client = await this.#ensureClient();
const threadId = await this.#ensureThread();
const prompt = JSON.stringify({
task: "polish-workspace-voice-announcement",
maxCharacters: this.#maxPhraseChars,
turn: context,
});
const started = await client.startTurn({
threadId,
input: [{ type: "text", text: prompt, text_elements: [] }],
model: this.#model,
cwd: this.#cwd,
approvalPolicy: "never",
sandboxPolicy: { type: "readOnly", networkAccess: false },
effort: this.#reasoningEffort,
outputSchema: announcerOutputSchema(this.#maxPhraseChars),
});
const completedTurn = await waitForTurn(client, {
threadId,
turnId: started.turn.id,
timeoutMs: this.#timeoutMs,
});
const text = finalTextFromTurn(completedTurn);
const decision = parseAnnouncerDecision(text);
if (!decision) {
this.#logger.warn("announcer.invalidOutput", {
threadId,
turnId: started.turn.id,
});
return new TemplateTurnAnnouncer().polish(context);
}
return decision;
}
close(): void {
this.#client?.close();
}
async #ensureClient(): Promise<CodexWorkspaceBackendClient> {
if (this.#client) {
return this.#client;
}
this.#client = new CodexWorkspaceBackendClient({
...this.#clientOptions,
webSocketTransportOptions: {
url: this.#workspaceBackendUrl,
requestTimeoutMs: this.#timeoutMs,
...this.#clientOptions.webSocketTransportOptions,
},
clientName: "codex-workspace-voice-announcer",
clientTitle: "Codex Workspace Voice Announcer",
clientVersion: "0.1.0",
});
await this.#client.connect();
return this.#client;
}
async #ensureThread(): Promise<string> {
await this.#ensureClient();
if (this.#threadId) {
return this.#threadId;
}
if (!this.#client) {
throw new Error("Announcer client is not initialized");
}
const response = await this.#client.startThread({
model: this.#model,
cwd: this.#cwd,
approvalPolicy: "never",
sandbox: "read-only",
baseInstructions: announcerInstructions,
ephemeral: true,
environments: [],
dynamicTools: [],
experimentalRawEvents: false,
persistExtendedHistory: false,
});
this.#threadId = response.thread.id;
return this.#threadId;
}
}
export function parseAnnouncerDecision(
rawText: string,
): AnnouncerDecision | undefined {
const parsed = parseJsonObject(rawText);
if (!parsed) {
return undefined;
}
const speak = parsed.speak;
const text = stringValue(parsed.text);
if (typeof speak !== "boolean" || !text) {
return undefined;
}
return {
speak,
text: cleanForSpeech(text),
priority: priorityValue(parsed.priority) ?? "normal",
};
}
export function fallbackAnnouncerDecision(
context: TurnCompletionContext,
error: unknown,
logger: Logger,
): AnnouncerDecision {
logger.warn("announcer.failed", { error: errorMessage(error) });
return {
speak: true,
priority: context.status === "completed" ? "normal" : "high",
text: cleanForSpeech(
context.errorMessage ||
context.finalText ||
`Workspace turn ${context.status}.`,
),
};
}
function announcerOutputSchema(
maxPhraseChars: number,
): NonNullable<v2.TurnStartParams["outputSchema"]> {
return {
type: "object",
additionalProperties: false,
required: ["speak", "text"],
properties: {
speak: { type: "boolean" },
priority: { type: "string", enum: ["low", "normal", "high"] },
text: { type: "string", maxLength: maxPhraseChars },
},
};
}
async function waitForTurn(
client: CodexWorkspaceBackendClient,
options: {
threadId: string;
turnId: string;
timeoutMs: number;
},
): Promise<v2.Turn> {
const startedAt = Date.now();
while (true) {
const response = await client.readThread({
threadId: options.threadId,
includeTurns: true,
});
const turn = response.thread.turns.find((entry) => entry.id === options.turnId);
if (!turn) {
throw new Error(`Announcer turn ${options.turnId} was not found`);
}
if (turn.status !== "inProgress") {
if (turn.status === "failed") {
throw new Error(turn.error?.message ?? `Announcer turn ${options.turnId} failed`);
}
return turn;
}
if (Date.now() - startedAt >= options.timeoutMs) {
throw new Error(`Timed out waiting for announcer turn ${options.turnId}`);
}
await delay(Math.min(1000, Math.max(0, options.timeoutMs - (Date.now() - startedAt))));
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseJsonObject(rawText: string): Record<string, unknown> | undefined {
const trimmed = rawText.trim();
if (!trimmed) {
return undefined;
}
try {
return record(JSON.parse(trimmed));
} catch {
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start < 0 || end <= start) {
return undefined;
}
try {
return record(JSON.parse(trimmed.slice(start, end + 1)));
} catch {
return undefined;
}
}
}
function priorityValue(value: unknown): AnnouncementPriority | undefined {
if (value === "low" || value === "normal" || value === "high") {
return value;
}
return undefined;
}

View file

@ -1,314 +0,0 @@
import type { ReasoningEffort } from "@peezy.tech/codex-flows/generated";
export type VoiceGatewayConfig = {
workspaceBackendUrl: string;
ttsWorkerUrl: string;
dryRun: boolean;
maxPhraseChars: number;
maxQueuedAnnouncements: number;
announceBackendConnected: boolean;
announceTurnStarted: boolean;
hookSpool: {
enabled: boolean;
dir: string;
};
discord: {
token: string | null;
guildId: string | null;
voiceChannelId: string | null;
};
tts: {
referenceAudioPath: string | null;
referenceText: string | null;
referenceTextPath: string | null;
};
announcer: {
enabled: boolean;
model: string;
reasoningEffort: ReasoningEffort;
timeoutMs: number;
cwd: string | null;
};
};
export type CliParseResult =
| { type: "config"; config: VoiceGatewayConfig }
| { type: "help"; text: string };
const defaultWorkspaceBackendUrl = "ws://127.0.0.1:3586";
const defaultTtsWorkerUrl = "http://127.0.0.1:8000";
export function parseCli(
argv: string[],
env: Record<string, string | undefined> = process.env,
): CliParseResult {
const flags = parseFlags(argv);
if (flags.has("help") || flags.has("h")) {
return { type: "help", text: helpText() };
}
const dryRun = booleanValue(flags, env, {
flag: "dry-run",
env: "CODEX_VOICE_DRY_RUN",
defaultValue: false,
});
const announcerEnabled = booleanValue(flags, env, {
flag: "announcer",
negativeFlag: "no-announcer",
env: "CODEX_VOICE_ANNOUNCER_ENABLED",
defaultValue: false,
});
const config: VoiceGatewayConfig = {
workspaceBackendUrl: normalizeWorkspaceBackendUrl(
stringOption(flags, env, [
"workspace-backend-url",
"workspace-url",
], [
"CODEX_VOICE_WORKSPACE_BACKEND_WS_URL",
"CODEX_WORKSPACE_BACKEND_WS_URL",
"CODEX_GATEWAY_BACKEND_URL",
]) ?? defaultWorkspaceBackendUrl,
),
ttsWorkerUrl: stripTrailingSlash(
stringOption(flags, env, ["tts-worker-url"], [
"CODEX_VOICE_TTS_WORKER_URL",
"DISCORD_TTS_WORKER_URL",
]) ?? defaultTtsWorkerUrl,
),
dryRun,
maxPhraseChars: numberOption(flags, env, "max-phrase-chars", "CODEX_VOICE_MAX_PHRASE_CHARS", 260),
maxQueuedAnnouncements: numberOption(flags, env, "max-queued-announcements", "CODEX_VOICE_MAX_QUEUE", 20),
announceBackendConnected: booleanValue(flags, env, {
flag: "announce-backend-connected",
negativeFlag: "no-announce-backend-connected",
env: "CODEX_VOICE_ANNOUNCE_BACKEND_CONNECTED",
defaultValue: true,
}),
announceTurnStarted: booleanValue(flags, env, {
flag: "announce-turn-started",
negativeFlag: "no-announce-turn-started",
env: "CODEX_VOICE_ANNOUNCE_TURN_STARTED",
defaultValue: false,
}),
hookSpool: {
enabled: booleanValue(flags, env, {
flag: "observe-hook-spool",
negativeFlag: "no-observe-hook-spool",
env: "CODEX_VOICE_OBSERVE_HOOK_SPOOL",
defaultValue: true,
}),
dir: stringOption(flags, env, ["hook-spool-dir"], [
"CODEX_VOICE_HOOK_SPOOL_DIR",
"CODEX_DISCORD_HOOK_SPOOL_DIR",
]) ?? "~/.codex/discord-bridge/stop-hooks",
},
discord: {
token: stringOption(flags, env, ["discord-token"], [
"CODEX_VOICE_DISCORD_BOT_TOKEN",
"CODEX_DISCORD_BOT_TOKEN",
"DISCORD_BOT_TOKEN",
]) ?? null,
guildId: stringOption(flags, env, ["discord-guild-id"], [
"CODEX_VOICE_DISCORD_GUILD_ID",
"CODEX_DISCORD_GUILD_ID",
"DISCORD_GUILD_ID",
]) ?? null,
voiceChannelId: stringOption(flags, env, [
"discord-voice-channel-id",
"discord-channel-id",
], [
"CODEX_VOICE_DISCORD_VOICE_CHANNEL_ID",
"CODEX_GATEWAY_DISCORD_VOICE_CHANNEL_ID",
"DISCORD_VOICE_CHANNEL_ID",
]) ?? null,
},
tts: {
referenceAudioPath: stringOption(flags, env, ["reference-audio-path"], [
"CODEX_VOICE_TTS_REFERENCE_AUDIO_PATH",
"DISCORD_TTS_REFERENCE_AUDIO_PATH",
]) ?? null,
referenceText: stringOption(flags, env, ["reference-text"], [
"CODEX_VOICE_TTS_REFERENCE_TEXT",
"DISCORD_TTS_REFERENCE_TEXT",
]) ?? null,
referenceTextPath: stringOption(flags, env, ["reference-text-path"], [
"CODEX_VOICE_TTS_REFERENCE_TEXT_PATH",
"DISCORD_TTS_REFERENCE_TEXT_PATH",
]) ?? null,
},
announcer: {
enabled: announcerEnabled,
model: stringOption(flags, env, ["announcer-model"], [
"CODEX_VOICE_ANNOUNCER_MODEL",
]) ?? "gpt-5.3-codex-spark",
reasoningEffort: reasoningEffortValue(
stringOption(flags, env, ["announcer-reasoning-effort"], [
"CODEX_VOICE_ANNOUNCER_REASONING_EFFORT",
]) ?? "low",
),
timeoutMs: numberOption(flags, env, "announcer-timeout-ms", "CODEX_VOICE_ANNOUNCER_TIMEOUT_MS", 90_000),
cwd: stringOption(flags, env, ["announcer-cwd"], [
"CODEX_VOICE_ANNOUNCER_CWD",
]) ?? null,
},
};
validateConfig(config);
return { type: "config", config };
}
export function helpText(): string {
return `Usage: codex-workspace-voice-gateway [options]
Broadcast selected Codex workspace backend updates into one Discord voice channel.
Options:
--workspace-backend-url <url> Workspace backend WebSocket URL.
--tts-worker-url <url> TTS worker HTTP URL.
--discord-token <token> Discord bot token.
--discord-guild-id <id> Discord guild id.
--discord-voice-channel-id <id> Discord voice channel id.
--reference-audio-path <path> TTS reference voice audio path.
--reference-text <text> TTS reference transcript.
--reference-text-path <path> TTS reference transcript path.
--announcer Enable model-polished turn-end phrases.
--announcer-model <model> Announcer model override.
--announcer-reasoning-effort <e> Announcer reasoning effort. Defaults to low.
--max-phrase-chars <n> Announcer phrase target. Defaults to 260.
--hook-spool-dir <path> Codex hook event spool directory.
--no-observe-hook-spool Do not watch external Codex hook events.
--dry-run Log announcements instead of joining Discord.
--help Show this help.
`;
}
function validateConfig(config: VoiceGatewayConfig): void {
if (!config.dryRun) {
const missing = [
["Discord bot token", config.discord.token],
["Discord voice channel id", config.discord.voiceChannelId],
].filter(([, value]) => !value);
if (missing.length > 0) {
throw new Error(
`Missing required voice gateway config: ${
missing.map(([name]) => name).join(", ")
}. Use --dry-run to skip Discord voice output.`,
);
}
}
}
function parseFlags(argv: string[]): Map<string, string | true> {
const flags = new Map<string, string | true>();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg?.startsWith("--")) {
throw new Error(`Unexpected positional argument: ${arg ?? ""}`);
}
const raw = arg.slice(2);
const equalsIndex = raw.indexOf("=");
if (equalsIndex >= 0) {
flags.set(raw.slice(0, equalsIndex), raw.slice(equalsIndex + 1));
continue;
}
const next = argv[index + 1];
if (next && !next.startsWith("--")) {
flags.set(raw, next);
index += 1;
continue;
}
flags.set(raw, true);
}
return flags;
}
function stringOption(
flags: Map<string, string | true>,
env: Record<string, string | undefined>,
names: string[],
envNames: string[],
): string | undefined {
for (const name of names) {
const value = flags.get(name);
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
for (const envName of envNames) {
const value = env[envName];
if (value?.trim()) {
return value.trim();
}
}
return undefined;
}
function numberOption(
flags: Map<string, string | true>,
env: Record<string, string | undefined>,
flagName: string,
envName: string,
defaultValue: number,
): number {
const raw = flags.get(flagName) ?? env[envName];
if (raw === undefined || raw === true || raw === "") {
return defaultValue;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
throw new Error(`Expected a non-negative number for ${flagName}`);
}
return value;
}
function booleanValue(
flags: Map<string, string | true>,
env: Record<string, string | undefined>,
options: {
flag: string;
negativeFlag?: string;
env: string;
defaultValue: boolean;
},
): boolean {
if (options.negativeFlag && flags.has(options.negativeFlag)) {
return false;
}
if (flags.has(options.flag)) {
const value = flags.get(options.flag);
return value === true ? true : value === "1" || value === "true";
}
const envValue = env[options.env];
if (envValue === undefined) {
return options.defaultValue;
}
return envValue === "1" || envValue.toLowerCase() === "true";
}
function stripTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}
function normalizeWorkspaceBackendUrl(value: string): string {
if (value.startsWith("http://")) {
return `ws://${value.slice("http://".length).replace(/\/+$/, "")}`;
}
if (value.startsWith("https://")) {
return `wss://${value.slice("https://".length).replace(/\/+$/, "")}`;
}
return value;
}
function reasoningEffortValue(value: string): ReasoningEffort {
if (
value === "none" ||
value === "minimal" ||
value === "low" ||
value === "medium" ||
value === "high" ||
value === "xhigh"
) {
return value;
}
throw new Error(`Unsupported announcer reasoning effort: ${value}`);
}

View file

@ -1,363 +0,0 @@
import { once } from "node:events";
import { readFile, unlink } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { PassThrough, Readable } from "node:stream";
import {
AudioPlayerStatus,
StreamType,
VoiceConnectionStatus,
createAudioPlayer,
createAudioResource,
entersState,
joinVoiceChannel,
type AudioPlayer,
type VoiceConnection,
} from "@discordjs/voice";
import {
ChannelType,
Client,
Events,
GatewayIntentBits,
type Guild,
type VoiceBasedChannel,
} from "discord.js";
import type { TtsAudioStream, TtsWorkerClient } from "./tts-client.ts";
import { errorMessage } from "./text.ts";
import type { Logger, Speaker } from "./types.ts";
export type DiscordVoiceSpeakerOptions = {
token: string;
guildId?: string | null;
voiceChannelId: string;
tts: TtsWorkerClient;
logger: Logger;
};
type ActivePlayback = {
cleanup(): void;
pumpTask: Promise<void>;
resource: ReturnType<typeof createAudioResource>;
};
export class DiscordVoiceSpeaker implements Speaker {
#token: string;
#guildId: string | null;
#voiceChannelId: string;
#tts: TtsWorkerClient;
#logger: Logger;
#client?: Client;
#connection?: VoiceConnection;
#player?: AudioPlayer;
#activePlayback: ActivePlayback | null = null;
#playbackResolver: (() => void) | null = null;
#closed = false;
#rejoining = false;
constructor(options: DiscordVoiceSpeakerOptions) {
this.#token = options.token;
this.#guildId = options.guildId ?? null;
this.#voiceChannelId = options.voiceChannelId;
this.#tts = options.tts;
this.#logger = options.logger;
}
async start(): Promise<void> {
this.#closed = false;
await this.#tts.ensureHealthy();
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
});
this.#client = client;
client.on("error", (error) => {
this.#logger.error("discord.client.error", { error: errorMessage(error) });
});
await new Promise<void>((resolve, reject) => {
const fail = (error: unknown) => reject(error);
client.once("error", fail);
client.once(Events.ClientReady, (readyClient) => {
const cachedGuild = this.#guildId
? readyClient.guilds.cache.get(this.#guildId) ?? null
: null;
void this.#join(cachedGuild)
.then(resolve, reject)
.finally(() => client.off("error", fail));
});
void client.login(this.#token).catch(fail);
});
}
async speak(text: string): Promise<void> {
const player = this.#player;
if (!player) {
throw new Error("Discord voice speaker is not started");
}
const outputPath = path.join(os.tmpdir(), `codex-voice-${randomUUID()}.wav`);
let filePath = outputPath;
try {
const file = await this.#tts.synthesizeFile(text, outputPath);
filePath = file.outputPath;
const playback = await createWavFileResource(filePath);
const playbackFinished = new Promise<void>((resolve) => {
this.#playbackResolver = resolve;
});
this.#activePlayback = playback;
player.play(playback.resource);
await playbackFinished;
await playback.pumpTask.catch(() => undefined);
} finally {
void unlink(filePath).catch(() => undefined);
}
}
async close(): Promise<void> {
this.#closed = true;
this.#activePlayback?.cleanup();
this.#connection?.destroy();
await this.#client?.destroy();
}
async #join(cachedGuild: Guild | null): Promise<void> {
const client = this.#client;
if (!client) {
throw new Error("Discord client is not initialized");
}
if (!this.#guildId) {
const channel = await client.channels.fetch(this.#voiceChannelId);
if (!channel || channel.type !== ChannelType.GuildVoice) {
throw new Error(`Discord channel ${this.#voiceChannelId} is not a guild voice channel`);
}
await this.#joinVoiceChannel(channel as VoiceBasedChannel);
return;
}
const guild = cachedGuild ?? await client.guilds.fetch(this.#guildId);
const channel = await guild.channels.fetch(this.#voiceChannelId);
if (!channel || channel.type !== ChannelType.GuildVoice) {
throw new Error(`Discord channel ${this.#voiceChannelId} is not a guild voice channel`);
}
await this.#joinVoiceChannel(channel as VoiceBasedChannel);
}
async #joinVoiceChannel(voiceChannel: VoiceBasedChannel): Promise<void> {
const guild = voiceChannel.guild;
const connection = joinVoiceChannel({
channelId: voiceChannel.id,
guildId: guild.id,
adapterCreator: guild.voiceAdapterCreator,
selfDeaf: true,
selfMute: false,
});
this.#connection = connection;
connection.on(VoiceConnectionStatus.Disconnected, () => {
void this.#recoverConnection(connection);
});
await entersState(connection, VoiceConnectionStatus.Ready, 30_000);
const player = createAudioPlayer();
player.on(AudioPlayerStatus.Idle, () => this.#resolvePlayback());
player.on("error", (error) => {
this.#logger.error("discord.player.error", { error: errorMessage(error) });
this.#resolvePlayback();
});
connection.subscribe(player);
this.#player = player;
this.#logger.info("discord.voice.connected", {
guild: guild.name,
channel: voiceChannel.name,
});
}
async #recoverConnection(connection: VoiceConnection): Promise<void> {
if (this.#closed || this.#rejoining) {
return;
}
this.#rejoining = true;
this.#logger.warn("discord.voice.disconnected");
try {
await Promise.race([
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
]);
} catch {
if (this.#connection === connection) {
connection.destroy();
this.#connection = undefined;
try {
await this.#join(null);
} catch (error) {
this.#logger.error("discord.voice.rejoinFailed", {
error: errorMessage(error),
});
}
}
} finally {
this.#rejoining = false;
}
}
#resolvePlayback(): void {
const playback = this.#activePlayback;
this.#activePlayback = null;
playback?.cleanup();
const resolve = this.#playbackResolver;
this.#playbackResolver = null;
resolve?.();
}
}
export async function createWavFileResource(filePath: string): Promise<ActivePlayback> {
const wav = await readFile(filePath);
const pcm = wavToDiscordPcm(wav);
const source = Readable.from([pcm]);
return {
resource: createAudioResource(source, { inputType: StreamType.Raw }),
cleanup() {
source.destroy();
},
pumpTask: Promise.resolve(),
};
}
export async function createStreamingPcmResource(
stream: TtsAudioStream,
prerollMs: number,
): Promise<ActivePlayback> {
if (stream.sampleRateHz !== 48000 || stream.channels !== 2) {
throw new Error(
`Expected Discord-ready PCM stream (48000 Hz stereo), got ${
stream.sampleRateHz
} Hz / ${stream.channels} channels`,
);
}
const bytesPerSecond = stream.sampleRateHz * stream.channels * 2;
const prerollBytes = Math.max(
Math.floor((bytesPerSecond * Math.max(prerollMs, 0)) / 1000),
0,
);
const reader = stream.body.getReader();
const initialChunks: Buffer[] = [];
let initialBytes = 0;
while (initialBytes < prerollBytes) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value || value.byteLength === 0) {
continue;
}
const chunk = Buffer.from(value);
initialChunks.push(chunk);
initialBytes += chunk.byteLength;
}
const source = new PassThrough({
highWaterMark: Math.max(prerollBytes, 256 * 1024),
});
for (const chunk of initialChunks) {
source.write(chunk);
}
let stopped = false;
const pumpTask = (async () => {
try {
while (!stopped) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value || value.byteLength === 0) {
continue;
}
if (!source.write(Buffer.from(value))) {
await once(source, "drain");
}
}
source.end();
} catch (error) {
if (!stopped) {
source.destroy(error instanceof Error ? error : new Error(String(error)));
}
} finally {
reader.releaseLock();
}
})();
return {
resource: createAudioResource(source, { inputType: StreamType.Raw }),
cleanup() {
stopped = true;
void reader.cancel().catch(() => undefined);
source.destroy();
},
pumpTask,
};
}
export function wavToDiscordPcm(wav: Buffer): Buffer {
const parsed = parsePcmWav(wav);
const targetSampleRateHz = 48000;
const targetChannels = 2;
const frameCount = parsed.data.length / (parsed.channels * 2);
const targetFrameCount = Math.max(
1,
Math.ceil((frameCount * targetSampleRateHz) / parsed.sampleRateHz),
);
const output = Buffer.alloc(targetFrameCount * targetChannels * 2);
for (let targetFrame = 0; targetFrame < targetFrameCount; targetFrame += 1) {
const sourceFrame = Math.min(
Math.floor((targetFrame * parsed.sampleRateHz) / targetSampleRateHz),
frameCount - 1,
);
const sourceOffset = sourceFrame * parsed.channels * 2;
const left = parsed.data.readInt16LE(sourceOffset);
const right = parsed.channels > 1
? parsed.data.readInt16LE(sourceOffset + 2)
: left;
const targetOffset = targetFrame * targetChannels * 2;
output.writeInt16LE(left, targetOffset);
output.writeInt16LE(right, targetOffset + 2);
}
return output;
}
function parsePcmWav(wav: Buffer): {
channels: number;
sampleRateHz: number;
data: Buffer;
} {
if (
wav.length < 44 ||
wav.toString("ascii", 0, 4) !== "RIFF" ||
wav.toString("ascii", 8, 12) !== "WAVE"
) {
throw new Error("Expected a RIFF/WAVE file from TTS worker");
}
let offset = 12;
let channels: number | undefined;
let sampleRateHz: number | undefined;
let bitsPerSample: number | undefined;
let audioFormat: number | undefined;
let data: Buffer | undefined;
while (offset + 8 <= wav.length) {
const chunkId = wav.toString("ascii", offset, offset + 4);
const chunkSize = wav.readUInt32LE(offset + 4);
const chunkStart = offset + 8;
const chunkEnd = chunkStart + chunkSize;
if (chunkEnd > wav.length) {
throw new Error("Malformed WAV chunk from TTS worker");
}
if (chunkId === "fmt ") {
audioFormat = wav.readUInt16LE(chunkStart);
channels = wav.readUInt16LE(chunkStart + 2);
sampleRateHz = wav.readUInt32LE(chunkStart + 4);
bitsPerSample = wav.readUInt16LE(chunkStart + 14);
} else if (chunkId === "data") {
data = wav.subarray(chunkStart, chunkEnd);
}
offset = chunkEnd + (chunkSize % 2);
}
if (audioFormat !== 1 || bitsPerSample !== 16 || !channels || !sampleRateHz || !data) {
throw new Error("Expected 16-bit PCM WAV audio from TTS worker");
}
return { channels, sampleRateHz, data };
}

View file

@ -1,174 +0,0 @@
import type { JsonRpcNotification } from "@peezy.tech/codex-flows/rpc";
import {
CodexWorkspaceBackendClient,
type WorkspaceBackendEvent,
} from "@peezy.tech/codex-flows/workspace-backend";
import {
draftFromNotification,
draftFromWorkspaceEvent,
type AnnouncementDraft,
type AnnouncementPolicy,
} from "./announcements.ts";
import {
fallbackAnnouncerDecision,
type TurnAnnouncer,
} from "./announcer.ts";
import { HookSpoolObserver } from "./hook-spool.ts";
import { SpeechQueue } from "./speech-queue.ts";
import { errorMessage } from "./text.ts";
import type { Logger, Speaker, VoiceAnnouncement } from "./types.ts";
export type WorkspaceVoiceGatewayOptions = {
workspaceBackendUrl: string;
workspaceClient?: CodexWorkspaceBackendClient;
speaker: Speaker;
logger: Logger;
maxQueuedAnnouncements: number;
announceBackendConnected: boolean;
announceTurnStarted: boolean;
hookSpool?: {
enabled: boolean;
dir: string;
};
announcer?: TurnAnnouncer;
};
export class WorkspaceVoiceGateway {
#client: CodexWorkspaceBackendClient;
#queue: SpeechQueue;
#logger: Logger;
#policy: AnnouncementPolicy;
#announcer?: TurnAnnouncer;
#hookSpool?: HookSpoolObserver;
constructor(options: WorkspaceVoiceGatewayOptions) {
this.#logger = options.logger;
this.#announcer = options.announcer;
this.#client = options.workspaceClient ??
new CodexWorkspaceBackendClient({
webSocketTransportOptions: {
url: options.workspaceBackendUrl,
requestTimeoutMs: 90_000,
},
clientName: "codex-workspace-voice-gateway",
clientTitle: "Codex Workspace Voice Gateway",
clientVersion: "0.1.0",
});
this.#queue = new SpeechQueue({
speaker: options.speaker,
logger: options.logger,
maxQueuedAnnouncements: options.maxQueuedAnnouncements,
});
this.#policy = {
announceBackendConnected: options.announceBackendConnected,
announceTurnStarted: options.announceTurnStarted,
ignoredThreadIds: this.#ignoredThreadIds(),
};
if (options.hookSpool?.enabled) {
this.#hookSpool = new HookSpoolObserver({
spoolDir: options.hookSpool.dir,
logger: options.logger,
onAnnouncement: (announcement) => this.#enqueue(announcement),
});
}
}
async start(): Promise<void> {
this.#client.on("workspaceBackendEvent", (event) =>
this.#handleWorkspaceBackendEvent(event as WorkspaceBackendEvent)
);
this.#client.on("notification", (message) =>
this.#handleNotification(message as JsonRpcNotification)
);
this.#client.on("error", (error) => {
this.#logger.error("workspaceBackend.error", { error: errorMessage(error) });
});
this.#client.on("close", (code, reason) => {
this.#logger.warn("workspaceBackend.closed", {
code: typeof code === "number" ? code : null,
reason: typeof reason === "string" ? reason : null,
});
});
await this.#queue.speaker.start?.();
await this.#hookSpool?.start();
await this.#client.connect();
this.#logger.info("gateway.started");
}
async close(): Promise<void> {
this.#hookSpool?.close();
this.#client.close();
this.#announcer?.close?.();
await this.#queue.close();
}
#handleWorkspaceBackendEvent(event: WorkspaceBackendEvent): void {
const draft = draftFromWorkspaceEvent(event, this.#currentPolicy());
if (draft) {
this.#enqueue(draft);
}
}
#handleNotification(message: JsonRpcNotification): void {
const draft = draftFromNotification(message, this.#currentPolicy());
if (!draft) {
return;
}
if (draft.turnCompletion && this.#announcer) {
void this.#polishAndEnqueue(draft);
return;
}
this.#enqueue(draft);
}
async #polishAndEnqueue(draft: AnnouncementDraft): Promise<void> {
const context = draft.turnCompletion;
if (!context || !this.#announcer) {
this.#enqueue(draft);
return;
}
try {
const decision = await this.#announcer.polish(context);
if (!decision.speak) {
this.#logger.debug?.("announcement.skippedByAnnouncer", {
threadId: context.threadId,
turnId: context.turnId,
});
return;
}
this.#enqueue({
id: `${draft.id}:announcer`,
text: decision.text,
priority: decision.priority,
source: "announcer",
});
} catch (error) {
const decision = fallbackAnnouncerDecision(
context,
error,
this.#logger,
);
this.#enqueue({
id: `${draft.id}:fallback`,
text: decision.text,
priority: decision.priority,
source: "announcer-fallback",
});
}
}
#enqueue(announcement: VoiceAnnouncement): void {
this.#queue.enqueue(announcement);
}
#currentPolicy(): AnnouncementPolicy {
return {
...this.#policy,
ignoredThreadIds: this.#ignoredThreadIds(),
};
}
#ignoredThreadIds(): ReadonlySet<string> {
return new Set(this.#announcer?.ignoredThreadIds?.() ?? []);
}
}

View file

@ -1,202 +0,0 @@
import { watch, type FSWatcher } from "node:fs";
import { mkdir, readdir, readFile, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { cleanForSpeech, errorMessage, record, stringValue } from "./text.ts";
import type { Logger, VoiceAnnouncement } from "./types.ts";
export type HookSpoolObserverOptions = {
spoolDir: string;
logger: Logger;
onAnnouncement(announcement: VoiceAnnouncement): void;
sinceMs?: number;
};
type HookSpoolEvent = {
id: string;
eventName: string;
sessionId: string;
turnId?: string;
cwd?: string;
lastAssistantMessage?: string;
stopHookActive?: boolean;
createdAt?: string;
};
const scanDebounceMs = 150;
export class HookSpoolObserver {
#spoolDir: string;
#pendingDir: string;
#logger: Logger;
#onAnnouncement: (announcement: VoiceAnnouncement) => void;
#sinceMs: number;
#seenFiles = new Set<string>();
#seenEvents = new Set<string>();
#watcher?: FSWatcher;
#timer?: ReturnType<typeof setTimeout>;
#closed = false;
#scanning = false;
constructor(options: HookSpoolObserverOptions) {
this.#spoolDir = expandHome(options.spoolDir);
this.#pendingDir = path.join(this.#spoolDir, "pending");
this.#logger = options.logger;
this.#onAnnouncement = options.onAnnouncement;
this.#sinceMs = options.sinceMs ?? Date.now();
}
async start(): Promise<void> {
await mkdir(this.#pendingDir, { recursive: true });
if (this.#closed) {
return;
}
this.#watcher = watch(this.#pendingDir, { persistent: false }, () => {
this.#scheduleScan();
});
this.#watcher.on("error", (error) => {
this.#logger.warn("hookSpool.watch.failed", { error: errorMessage(error) });
});
this.#scheduleScan(0);
}
close(): void {
this.#closed = true;
if (this.#timer) {
clearTimeout(this.#timer);
this.#timer = undefined;
}
this.#watcher?.close();
this.#watcher = undefined;
}
async scan(): Promise<void> {
if (this.#closed || this.#scanning) {
return;
}
this.#scanning = true;
try {
const files = (await readdir(this.#pendingDir))
.filter((fileName) => fileName.endsWith(".json"))
.sort();
for (const fileName of files) {
await this.#processFile(fileName);
}
} catch (error) {
this.#logger.warn("hookSpool.scan.failed", { error: errorMessage(error) });
} finally {
this.#scanning = false;
}
}
#scheduleScan(delayMs = scanDebounceMs): void {
if (this.#closed) {
return;
}
if (this.#timer) {
clearTimeout(this.#timer);
}
this.#timer = setTimeout(() => {
this.#timer = undefined;
void this.scan();
}, delayMs);
this.#timer.unref?.();
}
async #processFile(fileName: string): Promise<void> {
if (this.#seenFiles.has(fileName)) {
return;
}
const filePath = path.join(this.#pendingDir, fileName);
try {
const info = await stat(filePath);
if (info.mtimeMs < this.#sinceMs) {
this.#seenFiles.add(fileName);
return;
}
const event = parseHookSpoolEvent(JSON.parse(await readFile(filePath, "utf8")));
this.#seenFiles.add(fileName);
if (!event || this.#seenEvents.has(event.id)) {
return;
}
this.#seenEvents.add(event.id);
const announcement = announcementFromHookEvent(event);
if (announcement) {
this.#onAnnouncement(announcement);
}
} catch (error) {
if (isEnoent(error)) {
this.#seenFiles.add(fileName);
return;
}
this.#logger.debug?.("hookSpool.file.skipped", {
fileName,
error: errorMessage(error),
});
}
}
}
export function announcementFromHookEvent(
event: HookSpoolEvent,
): VoiceAnnouncement | undefined {
if (event.eventName !== "Stop" || event.stopHookActive === true) {
return undefined;
}
return {
id: `hook-stop:${event.id}`,
source: "codex-hook-spool",
priority: "normal",
text: cleanForSpeech(hookAnnouncementText(event)),
};
}
function hookAnnouncementText(event: HookSpoolEvent): string {
const workspace = event.cwd ? path.basename(event.cwd) : "that workspace";
const detail = event.lastAssistantMessage?.trim();
if (!detail) {
return `Hey, about ${workspace}. I just finished that turn.`;
}
return `Hey, about ${workspace}. I just finished: ${detail}`;
}
function parseHookSpoolEvent(input: unknown): HookSpoolEvent | undefined {
const parsed = record(input);
if (parsed.version !== 1) {
return undefined;
}
const id = stringValue(parsed.id);
const eventName = stringValue(parsed.eventName);
const sessionId = stringValue(parsed.sessionId);
if (!id || !eventName || !sessionId) {
return undefined;
}
return {
id,
eventName,
sessionId,
turnId: stringValue(parsed.turnId),
cwd: stringValue(parsed.cwd),
lastAssistantMessage: stringValue(parsed.lastAssistantMessage),
stopHookActive: typeof parsed.stopHookActive === "boolean"
? parsed.stopHookActive
: undefined,
createdAt: stringValue(parsed.createdAt),
};
}
function expandHome(value: string): string {
if (value === "~") {
return os.homedir();
}
if (value.startsWith("~/")) {
return path.join(os.homedir(), value.slice(2));
}
return value;
}
function isEnoent(error: unknown): boolean {
return error instanceof Error &&
"code" in error &&
String((error as NodeJS.ErrnoException).code) === "ENOENT";
}

View file

@ -1,88 +0,0 @@
#!/usr/bin/env node
import { pathToFileURL } from "node:url";
import { CodexTurnAnnouncer } from "./announcer.ts";
import { parseCli } from "./config.ts";
import { DiscordVoiceSpeaker } from "./discord-voice.ts";
import { WorkspaceVoiceGateway } from "./gateway.ts";
import { ConsoleSpeaker } from "./speech-queue.ts";
import { errorMessage } from "./text.ts";
import { TtsWorkerClient } from "./tts-client.ts";
import { consoleLogger } from "./types.ts";
async function main(): Promise<void> {
const parsed = parseCli(process.argv.slice(2), process.env);
if (parsed.type === "help") {
process.stdout.write(parsed.text);
return;
}
const config = parsed.config;
const logger = consoleLogger;
const tts = new TtsWorkerClient({
workerUrl: config.ttsWorkerUrl,
referenceAudioPath: config.tts.referenceAudioPath,
referenceText: config.tts.referenceText,
referenceTextPath: config.tts.referenceTextPath,
});
const speaker = config.dryRun
? new ConsoleSpeaker(logger)
: new DiscordVoiceSpeaker({
token: requireConfig(config.discord.token, "Discord token"),
guildId: config.discord.guildId,
voiceChannelId: requireConfig(
config.discord.voiceChannelId,
"Discord voice channel id",
),
tts,
logger,
});
const announcer = config.announcer.enabled
? new CodexTurnAnnouncer({
workspaceBackendUrl: config.workspaceBackendUrl,
model: config.announcer.model,
reasoningEffort: config.announcer.reasoningEffort,
timeoutMs: config.announcer.timeoutMs,
maxPhraseChars: config.maxPhraseChars,
cwd: config.announcer.cwd,
logger,
})
: undefined;
const gateway = new WorkspaceVoiceGateway({
workspaceBackendUrl: config.workspaceBackendUrl,
speaker,
logger,
maxQueuedAnnouncements: config.maxQueuedAnnouncements,
announceBackendConnected: config.announceBackendConnected,
announceTurnStarted: config.announceTurnStarted,
hookSpool: config.hookSpool,
announcer,
});
await gateway.start();
await waitForShutdown(gateway);
}
function requireConfig(value: string | null, label: string): string {
if (!value) {
throw new Error(`Missing ${label}`);
}
return value;
}
function waitForShutdown(gateway: WorkspaceVoiceGateway): Promise<void> {
return new Promise((resolve) => {
const shutdown = () => {
process.off("SIGINT", shutdown);
process.off("SIGTERM", shutdown);
void gateway.close().finally(resolve);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
});
}
const isDirectRun = import.meta.url === pathToFileURL(process.argv[1] ?? "").href;
if (isDirectRun) {
main().catch((error) => {
console.error(errorMessage(error));
process.exitCode = 1;
});
}

View file

@ -1,118 +0,0 @@
import { errorMessage } from "./text.ts";
import type { Logger, Speaker, VoiceAnnouncement } from "./types.ts";
export type SpeechQueueOptions = {
speaker: Speaker;
logger: Logger;
maxQueuedAnnouncements: number;
maxSeenAnnouncements?: number;
};
export class SpeechQueue {
readonly speaker: Speaker;
readonly logger: Logger;
#queue: VoiceAnnouncement[] = [];
#seen = new Set<string>();
#seenOrder: string[] = [];
#draining = false;
#closed = false;
#maxQueuedAnnouncements: number;
#maxSeenAnnouncements: number;
constructor(options: SpeechQueueOptions) {
this.speaker = options.speaker;
this.logger = options.logger;
this.#maxQueuedAnnouncements = options.maxQueuedAnnouncements;
this.#maxSeenAnnouncements = options.maxSeenAnnouncements ?? 1000;
}
enqueue(announcement: VoiceAnnouncement): boolean {
if (this.#closed || !announcement.text.trim()) {
return false;
}
if (this.#seen.has(announcement.id)) {
this.logger.debug?.("announcement.deduped", { id: announcement.id });
return false;
}
this.#remember(announcement.id);
if (this.#queue.length >= this.#maxQueuedAnnouncements) {
const dropped = this.#queue.shift();
this.logger.warn("announcement.dropped", {
id: dropped?.id,
reason: "queue-full",
});
}
if (announcement.priority === "high") {
this.#queue.unshift(announcement);
} else {
this.#queue.push(announcement);
}
this.#drain();
return true;
}
async close(): Promise<void> {
this.#closed = true;
this.#queue = [];
await this.speaker.close?.();
}
get size(): number {
return this.#queue.length;
}
#drain(): void {
if (this.#draining) {
return;
}
this.#draining = true;
void (async () => {
while (!this.#closed) {
const next = this.#queue.shift();
if (!next) {
break;
}
try {
this.logger.info("announcement.speak", {
id: next.id,
source: next.source,
priority: next.priority,
});
await this.speaker.speak(next.text);
} catch (error) {
this.logger.error("announcement.failed", {
id: next.id,
error: errorMessage(error),
});
}
}
this.#draining = false;
if (this.#queue.length > 0 && !this.#closed) {
this.#drain();
}
})();
}
#remember(id: string): void {
this.#seen.add(id);
this.#seenOrder.push(id);
while (this.#seenOrder.length > this.#maxSeenAnnouncements) {
const expired = this.#seenOrder.shift();
if (expired) {
this.#seen.delete(expired);
}
}
}
}
export class ConsoleSpeaker implements Speaker {
readonly logger: Logger;
constructor(logger: Logger) {
this.logger = logger;
}
async speak(text: string): Promise<void> {
this.logger.info("dry-run.speech", { text });
}
}

View file

@ -1,28 +0,0 @@
export function cleanForSpeech(text: string): string {
return text
.replace(/```[\s\S]*?```/g, " code block ")
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1")
.replace(/https?:\/\/\S+/g, "")
.replace(/<#[0-9]+>/g, "channel")
.replace(/<@!?[0-9]+>/g, "user")
.replace(/<@&[0-9]+>/g, "role")
.replace(/[*_~>#|]/g, " ")
.replace(/\b([0-9a-f]{7})[0-9a-f]{6,}\b/gi, "$1")
.replace(/[ \t\r\n]+/g, " ")
.trim();
}
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
export function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}

View file

@ -1,116 +0,0 @@
export type TtsWorkerClientOptions = {
workerUrl: string;
referenceAudioPath?: string | null;
referenceText?: string | null;
referenceTextPath?: string | null;
};
export type TtsAudioStream = {
body: ReadableStream<Uint8Array>;
requestId: string | null;
sampleRateHz: number;
channels: number;
};
export type TtsAudioFile = {
requestId: string;
outputPath: string;
sampleRateHz: number;
durationSeconds: number;
};
export class TtsWorkerClient {
#workerUrl: string;
#referenceAudioPath: string | null;
#referenceText: string | null;
#referenceTextPath: string | null;
constructor(options: TtsWorkerClientOptions) {
this.#workerUrl = options.workerUrl.replace(/\/+$/, "");
this.#referenceAudioPath = options.referenceAudioPath ?? null;
this.#referenceText = options.referenceText ?? null;
this.#referenceTextPath = options.referenceTextPath ?? null;
}
async ensureHealthy(): Promise<void> {
const response = await fetch(`${this.#workerUrl}/health`);
if (!response.ok) {
throw new Error(`TTS worker health check failed with ${response.status}`);
}
}
async synthesizeStream(text: string): Promise<TtsAudioStream> {
const response = await fetch(`${this.#workerUrl}/v1/synthesize/stream`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
text,
voice: "default",
reference_audio_path: this.#referenceAudioPath,
reference_text: this.#referenceText,
reference_text_path: this.#referenceTextPath,
}),
});
if (!response.ok) {
throw new Error(
`TTS stream synthesis failed with ${response.status}: ${await response.text()}`,
);
}
if (!response.body) {
throw new Error("TTS worker did not return streaming audio");
}
return {
body: response.body,
requestId: response.headers.get("x-request-id"),
sampleRateHz: Number(response.headers.get("x-sample-rate-hz") ?? "24000"),
channels: Number(response.headers.get("x-channels") ?? "1"),
};
}
async synthesizeFile(text: string, outputPath: string): Promise<TtsAudioFile> {
const response = await fetch(`${this.#workerUrl}/v1/synthesize`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
text,
voice: "default",
output_path: outputPath,
reference_audio_path: this.#referenceAudioPath,
reference_text: this.#referenceText,
reference_text_path: this.#referenceTextPath,
}),
});
if (!response.ok) {
throw new Error(
`TTS file synthesis failed with ${response.status}: ${await response.text()}`,
);
}
const body = await response.json() as Record<string, unknown>;
return {
requestId: stringValue(body.request_id) ?? "",
outputPath: requiredString(body.output_path, "output_path"),
sampleRateHz: numberValue(body.sample_rate_hz) ?? 24000,
durationSeconds: numberValue(body.duration_seconds) ?? 0,
};
}
}
function requiredString(value: unknown, label: string): string {
const parsed = stringValue(value);
if (!parsed) {
throw new Error(`TTS worker response is missing ${label}`);
}
return parsed;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

View file

@ -1,56 +0,0 @@
export type Logger = {
info(message: string, details?: Record<string, unknown>): void;
warn(message: string, details?: Record<string, unknown>): void;
error(message: string, details?: Record<string, unknown>): void;
debug?(message: string, details?: Record<string, unknown>): void;
};
export const consoleLogger: Logger = {
info(message, details) {
log("info", message, details);
},
warn(message, details) {
log("warn", message, details);
},
error(message, details) {
log("error", message, details);
},
debug(message, details) {
if (process.env.CODEX_VOICE_DEBUG === "1") {
log("debug", message, details);
}
},
};
export type AnnouncementPriority = "low" | "normal" | "high";
export type VoiceAnnouncement = {
id: string;
text: string;
priority: AnnouncementPriority;
source: string;
};
export type Speaker = {
start?(): Promise<void>;
speak(text: string): Promise<void>;
close?(): void | Promise<void>;
};
function log(
level: "info" | "warn" | "error" | "debug",
message: string,
details?: Record<string, unknown>,
): void {
const suffix = details ? ` ${JSON.stringify(details)}` : "";
const line = `[workspace-voice-gateway] ${level}: ${message}${suffix}`;
if (level === "error") {
console.error(line);
return;
}
if (level === "warn") {
console.warn(line);
return;
}
console.log(line);
}

View file

@ -1,101 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import type { JsonRpcNotification } from "@peezy.tech/codex-flows/rpc";
import {
draftFromNotification,
draftFromWorkspaceEvent,
finalTextFromTurn,
} from "../src/announcements.ts";
const policy = {
announceBackendConnected: true,
announceTurnStarted: false,
};
describe("announcement extraction", () => {
test("announces backend connection when enabled", () => {
const draft = draftFromWorkspaceEvent({
type: "connected",
at: "2026-05-16T00:00:00.000Z",
}, policy);
expect(draft?.text).toBe("Workspace backend connected.");
expect(draft?.priority).toBe("low");
});
test("extracts final turn text and removes speech-hostile formatting", () => {
const message: JsonRpcNotification = {
jsonrpc: "2.0",
method: "turn/completed",
params: {
threadId: "thread-1",
turn: {
id: "turn-1",
status: "completed",
durationMs: 1200,
error: null,
items: [
{
type: "agentMessage",
id: "m1",
phase: "commentary",
text: "Still working",
},
{
type: "agentMessage",
id: "m2",
phase: "final_answer",
text: "Implemented `voice gateway`. See https://example.com.\n```txt\nlogs\n```",
},
],
},
},
};
const draft = draftFromNotification(message, policy);
expect(draft?.kind).toBe("turn.completed");
expect(draft?.turnCompletion?.finalText).toContain("Implemented");
expect(draft?.text).toBe("Workspace turn completed. Implemented voice gateway. See code block");
});
test("ignores announcer thread notifications", () => {
const draft = draftFromNotification({
jsonrpc: "2.0",
method: "turn/started",
params: { threadId: "announcer", turn: { id: "turn-1" } },
}, {
...policy,
announceTurnStarted: true,
ignoredThreadIds: new Set(["announcer"]),
});
expect(draft).toBeUndefined();
});
test("announces failed hooks and turn errors", () => {
const hook = draftFromNotification({
jsonrpc: "2.0",
method: "hook/completed",
params: {
threadId: "thread-1",
run: {
id: "hook-1",
eventName: "turn-completed",
status: "failed",
statusMessage: "post hook failed",
},
},
}, policy);
expect(hook?.priority).toBe("high");
expect(hook?.text).toBe("Codex turn-completed hook failed. post hook failed");
});
});
describe("finalTextFromTurn", () => {
test("uses the last non-commentary agent message", () => {
expect(finalTextFromTurn({
items: [
{ type: "agentMessage", id: "one", phase: "final_answer", text: "first" },
{ type: "agentMessage", id: "two", phase: "commentary", text: "progress" },
{ type: "agentMessage", id: "three", phase: null, text: "last" },
],
})).toBe("last");
});
});

View file

@ -1,63 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import {
TemplateTurnAnnouncer,
parseAnnouncerDecision,
} from "../src/announcer.ts";
describe("parseAnnouncerDecision", () => {
test("accepts strict JSON and cleans text", () => {
expect(parseAnnouncerDecision(JSON.stringify({
speak: true,
priority: "high",
text: "Release check failed. See https://example.com for logs.",
}))).toEqual({
speak: true,
priority: "high",
text: "Release check failed. See for logs.",
});
});
test("finds JSON inside model wrapper text", () => {
const decision = parseAnnouncerDecision("```json\n{\"speak\":false,\"text\":\"skip\"}\n```");
expect(decision).toEqual({
speak: false,
priority: "normal",
text: "skip",
});
});
});
describe("TemplateTurnAnnouncer", () => {
test("speaks failures even without final text", async () => {
const announcer = new TemplateTurnAnnouncer();
const decision = await announcer.polish({
threadId: "thread",
turnId: "turn",
status: "failed",
durationMs: null,
finalText: "",
errorMessage: "Tests failed.",
});
expect(decision).toMatchObject({
speak: true,
priority: "high",
text: "Workspace turn failed. Tests failed.",
});
});
test("does not mechanically truncate long fallback text", async () => {
const announcer = new TemplateTurnAnnouncer();
const finalText = "Completed. ".repeat(80);
const decision = await announcer.polish({
threadId: "thread",
turnId: "turn",
status: "completed",
durationMs: null,
finalText,
errorMessage: null,
});
expect(decision.text).toContain(finalText.trim());
expect(decision.text).not.toContain("...");
});
});

View file

@ -1,63 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { parseCli } from "../src/config.ts";
describe("parseCli", () => {
test("supports dry-run without Discord credentials", () => {
const parsed = parseCli(["--dry-run"], {});
expect(parsed.type).toBe("config");
if (parsed.type === "config") {
expect(parsed.config.dryRun).toBe(true);
expect(parsed.config.workspaceBackendUrl).toBe("ws://127.0.0.1:3586");
expect(parsed.config.ttsWorkerUrl).toBe("http://127.0.0.1:8000");
expect(parsed.config.hookSpool.enabled).toBe(true);
expect(parsed.config.hookSpool.dir).toBe("~/.codex/discord-bridge/stop-hooks");
}
});
test("requires Discord credentials when not in dry-run mode", () => {
expect(() => parseCli([], {})).toThrow("Missing required voice gateway config");
});
test("does not require a guild id when a voice channel id is provided", () => {
const parsed = parseCli([], {
CODEX_DISCORD_BOT_TOKEN: "token",
CODEX_GATEWAY_DISCORD_VOICE_CHANNEL_ID: "voice",
});
expect(parsed.type).toBe("config");
if (parsed.type === "config") {
expect(parsed.config.discord.guildId).toBeNull();
expect(parsed.config.discord.voiceChannelId).toBe("voice");
}
});
test("reads workspace, Discord, TTS, and announcer config from env and flags", () => {
const parsed = parseCli([
"--announcer",
"--announcer-model",
"gpt-test",
"--max-phrase-chars",
"120",
], {
CODEX_GATEWAY_BACKEND_URL: "http://workspace.example/",
DISCORD_TTS_WORKER_URL: "http://127.0.0.1:8000/",
DISCORD_BOT_TOKEN: "token",
DISCORD_GUILD_ID: "guild",
DISCORD_VOICE_CHANNEL_ID: "voice",
DISCORD_TTS_REFERENCE_AUDIO_PATH: "references/jo.wav",
DISCORD_TTS_REFERENCE_TEXT_PATH: "references/jo.txt",
CODEX_VOICE_HOOK_SPOOL_DIR: "/tmp/hooks",
});
expect(parsed.type).toBe("config");
if (parsed.type === "config") {
expect(parsed.config.workspaceBackendUrl).toBe("ws://workspace.example");
expect(parsed.config.ttsWorkerUrl).toBe("http://127.0.0.1:8000");
expect(parsed.config.discord.token).toBe("token");
expect(parsed.config.tts.referenceAudioPath).toBe("references/jo.wav");
expect(parsed.config.hookSpool.dir).toBe("/tmp/hooks");
expect(parsed.config.announcer.enabled).toBe(true);
expect(parsed.config.announcer.model).toBe("gpt-test");
expect(parsed.config.maxPhraseChars).toBe(120);
}
});
});

View file

@ -1,127 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { CodexEventEmitter } from "@peezy.tech/codex-flows";
import type { JsonRpcNotification } from "@peezy.tech/codex-flows/rpc";
import {
APP_SERVER_NOTIFICATION_METHOD,
CodexWorkspaceBackendClient,
WORKSPACE_BACKEND_EVENT_METHOD,
type WorkspaceBackendInitializeResponse,
} from "@peezy.tech/codex-flows/workspace-backend";
import { WorkspaceVoiceGateway } from "../src/gateway.ts";
import type { Logger, Speaker } from "../src/types.ts";
const silentLogger: Logger = {
info() {},
warn() {},
error() {},
debug() {},
};
describe("WorkspaceVoiceGateway", () => {
test("observes workspace backend events and completed turns", async () => {
const transport = new FakeWorkspaceTransport();
const spoken: string[] = [];
const speaker: Speaker = {
async speak(text) {
spoken.push(text);
},
};
const gateway = new WorkspaceVoiceGateway({
workspaceBackendUrl: "ws://unused",
workspaceClient: new CodexWorkspaceBackendClient({ transport }),
speaker,
logger: silentLogger,
maxQueuedAnnouncements: 10,
announceBackendConnected: true,
announceTurnStarted: false,
});
await gateway.start();
transport.emitWorkspaceEvent({
type: "connected",
at: "2026-05-16T00:00:00.000Z",
});
transport.emitAppNotification({
jsonrpc: "2.0",
method: "turn/completed",
params: {
threadId: "thread-1",
turn: {
id: "turn-1",
status: "completed",
error: null,
durationMs: 1000,
items: [
{
type: "agentMessage",
id: "final",
phase: "final_answer",
text: "Packed the voice gateway and verified tests.",
},
],
},
},
});
await waitFor(() => spoken.length === 2);
expect(spoken).toEqual([
"Workspace backend connected.",
"Workspace turn completed. Packed the voice gateway and verified tests.",
]);
await gateway.close();
expect(transport.closed).toBe(true);
});
});
class FakeWorkspaceTransport extends CodexEventEmitter {
readonly requestTimeoutMs = 1000;
closed = false;
start(): void {}
close(): void {
this.closed = true;
}
async request<T = unknown>(method: string, _params?: unknown): Promise<T> {
if (method !== "workspace.initialize") {
throw new Error(`Unexpected request: ${method}`);
}
return {
ok: true,
serverInfo: { name: "fake", version: "0.1.0" },
capabilities: {
appServerPassThrough: true,
workspaceMethods: [],
},
} satisfies WorkspaceBackendInitializeResponse as T;
}
notify(_method: string, _params?: unknown): void {}
emitWorkspaceEvent(event: unknown): void {
this.emit("notification", {
jsonrpc: "2.0",
method: WORKSPACE_BACKEND_EVENT_METHOD,
params: { event },
});
}
emitAppNotification(message: JsonRpcNotification): void {
this.emit("notification", {
jsonrpc: "2.0",
method: APP_SERVER_NOTIFICATION_METHOD,
params: { message },
});
}
}
async function waitFor(predicate: () => boolean): Promise<void> {
for (let index = 0; index < 50; index += 1) {
if (predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error("Timed out waiting for predicate");
}

View file

@ -1,106 +0,0 @@
import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vite-plus/test";
import {
announcementFromHookEvent,
HookSpoolObserver,
} from "../src/hook-spool.ts";
import type { VoiceAnnouncement } from "../src/types.ts";
const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
});
describe("hook spool announcements", () => {
test("announces external Stop hook events without archiving files", async () => {
const root = await mkTempDir();
const pending = path.join(root, "pending");
await mkdir(pending, { recursive: true });
const filePath = path.join(pending, "stop-1.json");
await writeFile(
filePath,
`${JSON.stringify({
version: 1,
id: "stop-1",
eventName: "Stop",
sessionId: "thread-1",
turnId: "turn-1",
cwd: "/home/peezy/meta-workspace/codex-flows",
lastAssistantMessage: "Done. `vp test` passed.",
createdAt: new Date().toISOString(),
})}\n`,
);
const announcements: VoiceAnnouncement[] = [];
const observer = new HookSpoolObserver({
spoolDir: root,
logger: testLogger,
sinceMs: 0,
onAnnouncement: (announcement) => announcements.push(announcement),
});
await observer.start();
await observer.scan();
observer.close();
expect(announcements).toHaveLength(1);
expect(announcements[0]?.source).toBe("codex-hook-spool");
expect(announcements[0]?.text).toBe(
"Hey, about codex-flows. I just finished: Done. vp test passed.",
);
expect(await exists(filePath)).toBe(true);
});
test("uses a conversational fallback when there is no final text", () => {
const announcement = announcementFromHookEvent({
id: "stop-1",
eventName: "Stop",
sessionId: "thread-1",
cwd: "/home/peezy/meta-workspace",
});
expect(announcement?.text).toBe(
"Hey, about meta-workspace. I just finished that turn.",
);
});
test("skips non-Stop and active recursive Stop events", () => {
expect(announcementFromHookEvent({
id: "hook-1",
eventName: "PreToolUse",
sessionId: "thread-1",
})).toBeUndefined();
expect(announcementFromHookEvent({
id: "stop-1",
eventName: "Stop",
sessionId: "thread-1",
stopHookActive: true,
})).toBeUndefined();
});
});
async function mkTempDir(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), "voice-hook-spool-"));
tempDirs.push(dir);
return dir;
}
async function exists(filePath: string): Promise<boolean> {
try {
await stat(filePath);
return true;
} catch {
return false;
}
}
const testLogger = {
info() {},
warn() {},
error() {},
debug() {},
};

View file

@ -1,80 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { SpeechQueue } from "../src/speech-queue.ts";
import type { Logger, Speaker } from "../src/types.ts";
const silentLogger: Logger = {
info() {},
warn() {},
error() {},
debug() {},
};
describe("SpeechQueue", () => {
test("dedupes announcements by id", async () => {
const spoken: string[] = [];
const speaker: Speaker = {
async speak(text) {
spoken.push(text);
},
};
const queue = new SpeechQueue({
speaker,
logger: silentLogger,
maxQueuedAnnouncements: 10,
});
expect(queue.enqueue({
id: "same",
text: "hello",
priority: "normal",
source: "test",
})).toBe(true);
expect(queue.enqueue({
id: "same",
text: "hello again",
priority: "normal",
source: "test",
})).toBe(false);
await waitFor(() => spoken.length === 1);
expect(spoken).toEqual(["hello"]);
await queue.close();
});
test("prioritizes high priority queued items after current playback", async () => {
const spoken: string[] = [];
let releaseFirst: (() => void) | undefined;
const speaker: Speaker = {
async speak(text) {
spoken.push(text);
if (text === "first") {
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
}
},
};
const queue = new SpeechQueue({
speaker,
logger: silentLogger,
maxQueuedAnnouncements: 10,
});
queue.enqueue({ id: "1", text: "first", priority: "normal", source: "test" });
queue.enqueue({ id: "2", text: "second", priority: "normal", source: "test" });
queue.enqueue({ id: "3", text: "urgent", priority: "high", source: "test" });
await waitFor(() => spoken.length === 1);
releaseFirst?.();
await waitFor(() => spoken.length === 3);
expect(spoken).toEqual(["first", "urgent", "second"]);
await queue.close();
});
});
async function waitFor(predicate: () => boolean): Promise<void> {
for (let index = 0; index < 50; index += 1) {
if (predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error("Timed out waiting for predicate");
}

View file

@ -1,240 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import http from "node:http";
import { createStreamingPcmResource, wavToDiscordPcm } from "../src/discord-voice.ts";
import { TtsWorkerClient } from "../src/tts-client.ts";
describe("TtsWorkerClient", () => {
test("checks health and requests streaming synthesis with reference voice config", async () => {
let receivedPayload: Record<string, unknown> | undefined;
const server = await createHttpTestServer(
async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") {
return Response.json({ status: "ok", engine: "test" });
}
if (url.pathname === "/v1/synthesize/stream") {
receivedPayload = await request.json() as Record<string, unknown>;
return new Response(pcmStream(), {
headers: {
"x-request-id": "request-1",
"x-sample-rate-hz": "48000",
"x-channels": "2",
},
});
}
return new Response("not found", { status: 404 });
},
);
try {
const client = new TtsWorkerClient({
workerUrl: server.url,
referenceAudioPath: "references/jo.wav",
referenceTextPath: "references/jo.txt",
});
await client.ensureHealthy();
const stream = await client.synthesizeStream("hello workspace");
expect(stream.requestId).toBe("request-1");
expect(stream.sampleRateHz).toBe(48000);
expect(stream.channels).toBe(2);
expect(receivedPayload).toMatchObject({
text: "hello workspace",
reference_audio_path: "references/jo.wav",
reference_text_path: "references/jo.txt",
});
} finally {
await server.close();
}
});
test("requests file synthesis with an explicit output path", async () => {
let receivedPayload: Record<string, unknown> | undefined;
const server = await createHttpTestServer(
async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") {
return Response.json({ status: "ok", engine: "test" });
}
if (url.pathname === "/v1/synthesize") {
receivedPayload = await request.json() as Record<string, unknown>;
return Response.json({
request_id: "file-request-1",
engine: "NeuTTS",
voice: "default",
output_path: "/tmp/codex-voice.wav",
format: "wav",
sample_rate_hz: 24000,
duration_seconds: 1.25,
});
}
return new Response("not found", { status: 404 });
},
);
try {
const client = new TtsWorkerClient({
workerUrl: server.url,
referenceAudioPath: "references/jo.wav",
referenceTextPath: "references/jo.txt",
});
const file = await client.synthesizeFile("hello workspace", "/tmp/codex-voice.wav");
expect(file).toEqual({
requestId: "file-request-1",
outputPath: "/tmp/codex-voice.wav",
sampleRateHz: 24000,
durationSeconds: 1.25,
});
expect(receivedPayload).toMatchObject({
text: "hello workspace",
output_path: "/tmp/codex-voice.wav",
reference_audio_path: "references/jo.wav",
reference_text_path: "references/jo.txt",
});
} finally {
await server.close();
}
});
});
describe("createStreamingPcmResource", () => {
test("accepts Discord-ready PCM streams", async () => {
const playback = await createStreamingPcmResource({
body: pcmStream(),
requestId: "request-1",
sampleRateHz: 48000,
channels: 2,
}, 0);
playback.cleanup();
await playback.pumpTask.catch(() => undefined);
expect(playback.resource).toBeDefined();
});
test("rejects non-Discord PCM streams", async () => {
await expect(createStreamingPcmResource({
body: pcmStream(),
requestId: "request-1",
sampleRateHz: 24000,
channels: 1,
}, 0)).rejects.toThrow("Expected Discord-ready PCM stream");
});
test("converts 24 kHz mono WAV to Discord-ready PCM", () => {
const wav = pcmWav({
sampleRateHz: 24000,
channels: 1,
samples: [1000, -1000],
});
const pcm = wavToDiscordPcm(wav);
expect(pcm.length).toBe(16);
expect(pcm.readInt16LE(0)).toBe(1000);
expect(pcm.readInt16LE(2)).toBe(1000);
expect(pcm.readInt16LE(4)).toBe(1000);
expect(pcm.readInt16LE(6)).toBe(1000);
expect(pcm.readInt16LE(8)).toBe(-1000);
expect(pcm.readInt16LE(10)).toBe(-1000);
});
});
function pcmStream(): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(3840));
controller.close();
},
});
}
async function createHttpTestServer(
handler: (request: Request) => Response | Promise<Response>,
): Promise<{ url: string; close: () => Promise<void> }> {
const server = http.createServer((request, response) => {
void (async () => {
const webResponse = await handler(await toWebRequest(request));
response.statusCode = webResponse.status;
for (const [name, value] of webResponse.headers) {
response.setHeader(name, value);
}
response.end(Buffer.from(await webResponse.arrayBuffer()));
})().catch((error: unknown) => {
response.statusCode = 500;
response.end(error instanceof Error ? error.message : String(error));
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (typeof address !== "object" || !address) {
throw new Error("test server did not start");
}
return {
url: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
}),
};
}
async function toWebRequest(request: http.IncomingMessage): Promise<Request> {
const host = request.headers.host ?? "127.0.0.1";
const url = new URL(request.url ?? "/", `http://${host}`);
const body = request.method === "GET" || request.method === "HEAD"
? undefined
: await collectBody(request);
return new Request(url, {
method: request.method,
headers: nodeHeaders(request.headers),
body,
});
}
function nodeHeaders(headers: http.IncomingHttpHeaders): Headers {
const result = new Headers();
for (const [name, value] of Object.entries(headers)) {
if (Array.isArray(value)) {
for (const entry of value) {
result.append(name, entry);
}
} else if (value !== undefined) {
result.set(name, value);
}
}
return result;
}
async function collectBody(request: http.IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
function pcmWav(input: {
sampleRateHz: number;
channels: number;
samples: number[];
}): Buffer {
const data = Buffer.alloc(input.samples.length * 2);
for (const [index, sample] of input.samples.entries()) {
data.writeInt16LE(sample, index * 2);
}
const header = Buffer.alloc(44);
header.write("RIFF", 0, "ascii");
header.writeUInt32LE(36 + data.length, 4);
header.write("WAVE", 8, "ascii");
header.write("fmt ", 12, "ascii");
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(input.channels, 22);
header.writeUInt32LE(input.sampleRateHz, 24);
header.writeUInt32LE(input.sampleRateHz * input.channels * 2, 28);
header.writeUInt16LE(input.channels * 2, 32);
header.writeUInt16LE(16, 34);
header.write("data", 36, "ascii");
header.writeUInt32LE(data.length, 40);
return Buffer.concat([header, data]);
}

View file

@ -1,28 +0,0 @@
{
"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"],
"baseUrl": ".",
"paths": {
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/generated": ["../../packages/codex-client/src/app-server/generated/index.ts"],
"@peezy.tech/codex-flows/generated/*": ["../../packages/codex-client/src/app-server/generated/*"],
"@peezy.tech/codex-flows/rpc": ["../../packages/codex-client/src/app-server/rpc.ts"],
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"]
}
},
"include": ["src", "test"]
}

View file

@ -96,7 +96,7 @@ node "${PLUGIN_ROOT}/hooks/hook-event.mjs"
Codex expands `${PLUGIN_ROOT}` from the installed plugin bundle. The command is
self-contained and writes lifecycle events into the hook spool used by
workspace surfaces. Override the spool with `CODEX_FLOWS_HOOK_SPOOL_DIR`, or
with `CODEX_DISCORD_HOOK_SPOOL_DIR` for the existing Discord bridge and voice
with `CODEX_DISCORD_HOOK_SPOOL_DIR` for external Discord bridge and voice
gateway consumers.
## Local backend setup

View file

@ -1,64 +0,0 @@
procs:
workspace-backend:
shell: |
bash -lc 'set -o pipefail
set -a
[ ! -f .env ] || source .env
[ ! -f .env.local ] || source .env.local
set +a
backend_url="${CODEX_VOICE_WORKSPACE_BACKEND_WS_URL:-${CODEX_WORKSPACE_BACKEND_WS_URL:-${CODEX_GATEWAY_BACKEND_URL:-ws://127.0.0.1:3586}}}"
backend_port="$(node -e "const url = new URL(process.argv[1]); console.log(url.port || (url.protocol === \"https:\" || url.protocol === \"wss:\" ? \"443\" : \"80\"));" "$backend_url")"
vp run workspace:backend --local-app-server --port "$backend_port"'
autostart: true
autorestart: true
tts-worker:
shell: |
bash -lc 'set -o pipefail
set -a
[ ! -f .env ] || source .env
[ ! -f .env.local ] || source .env.local
cd ../tts
[ ! -f .env ] || source .env
[ ! -f .env.local ] || source .env.local
set +a
export TTS_WORKER_BACKBONE_REPO="${TTS_WORKER_BACKBONE_REPO:-neuphonic/neutts-nano-q4-gguf}"
export TTS_WORKER_BACKBONE_DEVICE="${TTS_WORKER_BACKBONE_DEVICE:-cpu}"
export TTS_WORKER_CODEC_REPO="${TTS_WORKER_CODEC_REPO:-neuphonic/neucodec}"
export TTS_WORKER_CODEC_DEVICE="${TTS_WORKER_CODEC_DEVICE:-cpu}"
export TTS_WORKER_DEFAULT_REFERENCE_AUDIO_PATH="${TTS_WORKER_DEFAULT_REFERENCE_AUDIO_PATH:-${DISCORD_TTS_REFERENCE_AUDIO_PATH:-references/jo.wav}}"
export TTS_WORKER_DEFAULT_REFERENCE_TEXT_PATH="${TTS_WORKER_DEFAULT_REFERENCE_TEXT_PATH:-${DISCORD_TTS_REFERENCE_TEXT_PATH:-references/jo.txt}}"
uv run tts-worker'
autostart: true
autorestart: true
workspace-voice-gateway:
shell: |
bash -lc 'set -o pipefail
set -a
[ ! -f .env ] || source .env
[ ! -f .env.local ] || source .env.local
set +a
backend_url="${CODEX_VOICE_WORKSPACE_BACKEND_WS_URL:-${CODEX_WORKSPACE_BACKEND_WS_URL:-${CODEX_GATEWAY_BACKEND_URL:-ws://127.0.0.1:3586}}}"
tts_url="${CODEX_VOICE_TTS_WORKER_URL:-${DISCORD_TTS_WORKER_URL:-http://127.0.0.1:8000}}"
voice_channel_id="${CODEX_VOICE_DISCORD_VOICE_CHANNEL_ID:-${CODEX_GATEWAY_DISCORD_VOICE_CHANNEL_ID:-${DISCORD_VOICE_CHANNEL_ID:-}}}"
if [ -z "$voice_channel_id" ]; then
echo "Set CODEX_GATEWAY_DISCORD_VOICE_CHANNEL_ID in .env before starting the voice gateway." >&2
exit 1
fi
backend_host="$(node -e "const url = new URL(process.argv[1]); console.log(url.hostname);" "$backend_url")"
backend_port="$(node -e "const url = new URL(process.argv[1]); console.log(url.port || (url.protocol === \"https:\" || url.protocol === \"wss:\" ? \"443\" : \"80\"));" "$backend_url")"
until (echo >"/dev/tcp/$backend_host/$backend_port") >/dev/null 2>&1; do
echo "waiting for workspace backend at $backend_url"
sleep 2
done
until curl -fsS "$tts_url/health" >/dev/null 2>&1; do
echo "waiting for TTS worker at $tts_url"
sleep 2
done
tsx ./apps/workspace-voice-gateway/src/index.ts \
--workspace-backend-url "$backend_url" \
--tts-worker-url "$tts_url" \
--discord-voice-channel-id "$voice_channel_id"'
autostart: true
autorestart: true

View file

@ -1,42 +0,0 @@
procs:
codex-remote-control:
shell: |
bash -lc 'set -o pipefail
set -a
[ ! -f .env ] || source .env
[ ! -f .env.local ] || source .env.local
set +a
app_server_url="${CODEX_WORKSPACE_APP_SERVER_WS_URL:-ws://127.0.0.1:3585}"
codex app-server \
--listen "$app_server_url" \
--enable apps \
--enable hooks \
--enable remote_control \
2>&1 | tsx ./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
[ ! -f .env.local ] || source .env.local
set +a
app_server_url="${CODEX_WORKSPACE_APP_SERVER_WS_URL:-ws://127.0.0.1:3585}"
app_server_host="$(node -e "const url = new URL(process.argv[1]); console.log(url.hostname);" "$app_server_url")"
app_server_port="$(node -e "const url = new URL(process.argv[1]); console.log(url.port || (url.protocol === \"https:\" || url.protocol === \"wss:\" ? \"443\" : \"80\"));" "$app_server_url")"
until (echo >"/dev/tcp/$app_server_host/$app_server_port") >/dev/null 2>&1; do
echo "waiting for codex app-server at $app_server_url" >&2
sleep 1
done
tsx ./apps/discord-bridge/src/index.ts \
--log-level "${CODEX_DISCORD_LOG_LEVEL:-warn}" \
--console-output messages \
--approval-policy never \
--sandbox danger-full-access \
--progress-mode commentary \
--app-server-url "$app_server_url" \
2> >(tsx ./apps/discord-bridge/src/pretty-log.ts --name discord-bridge >&2)'
autostart: true
autorestart: false

411
pnpm-lock.yaml generated
View file

@ -106,25 +106,6 @@ importers:
specifier: 'catalog:'
version: 5.9.3
apps/discord-bridge:
dependencies:
'@peezy.tech/codex-flows':
specifier: workspace:*
version: link:../../packages/codex-client
discord.js:
specifier: ^14.22.1
version: 14.26.4
smol-toml:
specifier: 'catalog:'
version: 1.6.1
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 24.12.4
typescript:
specifier: 'catalog:'
version: 5.9.3
apps/web:
dependencies:
'@peezy.tech/codex-flows':
@ -187,28 +168,6 @@ importers:
specifier: 'catalog:'
version: 5.9.3
apps/workspace-voice-gateway:
dependencies:
'@discordjs/voice':
specifier: ^0.19.2
version: 0.19.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(opusscript@0.1.1)
'@peezy.tech/codex-flows':
specifier: workspace:*
version: link:../../packages/codex-client
discord.js:
specifier: ^14.22.1
version: 14.26.4
opusscript:
specifier: ^0.1.1
version: 0.1.1
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 24.12.4
typescript:
specifier: 'catalog:'
version: 5.9.3
docs:
devDependencies:
'@tomehq/cli':
@ -524,38 +483,6 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@discordjs/builders@1.14.1':
resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==}
engines: {node: '>=16.11.0'}
'@discordjs/collection@1.5.3':
resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==}
engines: {node: '>=16.11.0'}
'@discordjs/collection@2.1.1':
resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==}
engines: {node: '>=18'}
'@discordjs/formatters@0.6.2':
resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==}
engines: {node: '>=16.11.0'}
'@discordjs/rest@2.6.1':
resolution: {integrity: sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==}
engines: {node: '>=18'}
'@discordjs/util@1.2.0':
resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==}
engines: {node: '>=18'}
'@discordjs/voice@0.19.2':
resolution: {integrity: sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==}
engines: {node: '>=22.12.0'}
'@discordjs/ws@1.2.3':
resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==}
engines: {node: '>=16.11.0'}
'@dotenvx/dotenvx@1.66.0':
resolution: {integrity: sha512-qlQFhHUjhRDybrinqLAD0MClVZDOrsq80O8eD5iSjz3Qa/4f3Jg7SQrOaSobrRyP1QaWIYLGtGpj2c7H0D8NUw==}
hasBin: true
@ -1412,22 +1339,6 @@ packages:
cpu: [x64]
os: [win32]
'@sapphire/async-queue@1.5.5':
resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
'@sapphire/shapeshift@4.0.0':
resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==}
engines: {node: '>=v16'}
'@sapphire/snowflake@3.5.3':
resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
'@sapphire/snowflake@3.5.5':
resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@ -1472,97 +1383,6 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@snazzah/davey-android-arm-eabi@0.1.11':
resolution: {integrity: sha512-T1RYbNYKN6tLOcGIDKJd8OI6FBSEemwL7DOYdTMmhqfhhMr3YVN8WOhfoxGg63OcnpTN2e2c5tdY2bAx25RmQQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@snazzah/davey-android-arm64@0.1.11':
resolution: {integrity: sha512-ksJn/x2VU8h6w9eku1HT96ugSRZ7lKVkKNKbFleaFN+U99DJaPM+gMu2YvnFU4V54HR06ZBnRihnVG6VLXQpDw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@snazzah/davey-darwin-arm64@0.1.11':
resolution: {integrity: sha512-E1d7PbaaVMO3Lj9EiAPqOVbuV0xg5+PsHzHH097DDXiD1+zUDXvJaTnUWsnm5z50pJniHpi4GtaYmk+ieB/guA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@snazzah/davey-darwin-x64@0.1.11':
resolution: {integrity: sha512-Tl4TI/LTmgJZepgbgVMYDi8RqlAkPtPg1OEBPl7a9Tn3AwR36Vs6lyIT1cs/lGy/ds/+B+mKI4rPObN1cyILTw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@snazzah/davey-freebsd-x64@0.1.11':
resolution: {integrity: sha512-T8Iw9FXkuI1T+YBAFzh9v/TXf9IOTOSqnd/BFpTRTrlW72PR2lhIidzSmg027VxO7r5pX47iFwiOkb9I/NU/EA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@snazzah/davey-linux-arm-gnueabihf@0.1.11':
resolution: {integrity: sha512-1Txj+8pqA8uq/OGtaUaBFWAPnNMQzFgIywj0iA7EI4xZl+mab48/pv+YZ1pNb/suC6ynsW44oB9efiXSdcUAgA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@snazzah/davey-linux-arm64-gnu@0.1.11':
resolution: {integrity: sha512-ERzF5nM/IYW1BcN3wLXpEwBCGLFf0kGJUVhaV6yfiInz0tkU8UmvrrgpaMaACfMjIhfWdq5CcX+aTkXo/saNcg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-arm64-musl@0.1.11':
resolution: {integrity: sha512-e6pX6Hiabtz99q+H/YHNkm9JVlpqN8HGh0qPib8G2+UY4/SSH8WvqWipk3v581dMy2oyCHt7MOoY1aU1P1N/xA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@snazzah/davey-linux-x64-gnu@0.1.11':
resolution: {integrity: sha512-TW5bSoqChOJMbvsDb4wAATYrxmAXuNnse7wFNVSAJUaZKSeRfZbu3UAiPWSNn7GwLwSfU6hg322KZUn8IWCuvg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-x64-musl@0.1.11':
resolution: {integrity: sha512-5j6Pmc+Wzv5lSxVP6quA7teYRJXibkZqQyYGfTDnTsUOO5dPpcojpqlXlkhyvsA1OAQTj4uxbOCciN3cVWwzug==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@snazzah/davey-wasm32-wasi@0.1.11':
resolution: {integrity: sha512-rKOwZ/0J8lp+4VEyOdMDBRP9KR+PksZpa9V1Qn0veMzy4FqTVKthkxwGqewheFe0SFg9fdvt798l/PBFrfDeZw==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@snazzah/davey-win32-arm64-msvc@0.1.11':
resolution: {integrity: sha512-5fptJU4tX901m3mj0SHiBljMrPT4ZEsynbBhR7bK1yn9TY1jjyhN8EFi7QF5IWtUEni+0mia2BCMHZ5ZkmFZqQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@snazzah/davey-win32-ia32-msvc@0.1.11':
resolution: {integrity: sha512-ualexn8SeLsiMHhWfzVrzRcjHgcBapg++FPaVgJJxoh2S/jCRiklXOu3luqIZdJdNKvhe2V9SwO/cImPeIIBKw==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@snazzah/davey-win32-x64-msvc@0.1.11':
resolution: {integrity: sha512-muNhc8UKXtknzsH/w4AIkbPR2I8BuvApn0pDXar0IEvY8PCjqU/M8MPbOOEYwQVvQRMwVTgExtxzrkBPSXB4nA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@snazzah/davey@0.1.11':
resolution: {integrity: sha512-oBN+msHzPnm1M5DDx3wVD7iBwpNXFUtkh2MrAbUJu0OhKjliLChi28hq++mu1+qdMpAVQO5JKAvQQxYVbyneiw==}
engines: {node: '>= 10'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@ -1779,10 +1599,6 @@ packages:
babel-plugin-react-compiler:
optional: true
'@vladfrangu/async_event_emitter@2.4.7':
resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
'@voidzero-dev/vite-plus-core@0.1.22':
resolution: {integrity: sha512-OC7tChagbJCoY7YKzD5MuyxJO1km5IF42B3ltZoQ9Twc8UuPrMuWZrVoP984tJKYd/gFJuQFM/lrbNtBm9kyDg==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -2242,13 +2058,6 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
discord-api-types@0.38.47:
resolution: {integrity: sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==}
discord.js@14.26.4:
resolution: {integrity: sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==}
engines: {node: '>=18'}
dompurify@3.4.5:
resolution: {integrity: sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==}
@ -2884,12 +2693,6 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
lodash.snakecase@4.1.1:
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
log-symbols@6.0.0:
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
engines: {node: '>=18'}
@ -2909,9 +2712,6 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-bytes.js@1.13.0:
resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -3227,9 +3027,6 @@ packages:
openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
opusscript@0.1.1:
resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==}
ora@8.2.0:
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
engines: {node: '>=18'}
@ -3341,23 +3138,6 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
prism-media@1.3.5:
resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==}
peerDependencies:
'@discordjs/opus': '>=0.8.0 <1.0.0'
ffmpeg-static: ^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0
node-opus: ^0.3.3
opusscript: ^0.0.8
peerDependenciesMeta:
'@discordjs/opus':
optional: true
ffmpeg-static:
optional: true
node-opus:
optional: true
opusscript:
optional: true
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@ -3731,9 +3511,6 @@ packages:
trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
ts-mixer@6.0.4:
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
ts-morph@26.0.0:
resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==}
@ -3776,10 +3553,6 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@6.24.1:
resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==}
engines: {node: '>=18.17'}
undici@7.25.0:
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
engines: {node: '>=20.18.1'}
@ -4284,73 +4057,6 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
'@discordjs/builders@1.14.1':
dependencies:
'@discordjs/formatters': 0.6.2
'@discordjs/util': 1.2.0
'@sapphire/shapeshift': 4.0.0
discord-api-types: 0.38.47
fast-deep-equal: 3.1.3
ts-mixer: 6.0.4
tslib: 2.8.1
'@discordjs/collection@1.5.3': {}
'@discordjs/collection@2.1.1': {}
'@discordjs/formatters@0.6.2':
dependencies:
discord-api-types: 0.38.47
'@discordjs/rest@2.6.1':
dependencies:
'@discordjs/collection': 2.1.1
'@discordjs/util': 1.2.0
'@sapphire/async-queue': 1.5.5
'@sapphire/snowflake': 3.5.5
'@vladfrangu/async_event_emitter': 2.4.7
discord-api-types: 0.38.47
magic-bytes.js: 1.13.0
tslib: 2.8.1
undici: 6.24.1
'@discordjs/util@1.2.0':
dependencies:
discord-api-types: 0.38.47
'@discordjs/voice@0.19.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(opusscript@0.1.1)':
dependencies:
'@snazzah/davey': 0.1.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
'@types/ws': 8.18.1
discord-api-types: 0.38.47
prism-media: 1.3.5(opusscript@0.1.1)
tslib: 2.8.1
ws: 8.20.1
transitivePeerDependencies:
- '@discordjs/opus'
- '@emnapi/core'
- '@emnapi/runtime'
- bufferutil
- ffmpeg-static
- node-opus
- opusscript
- utf-8-validate
'@discordjs/ws@1.2.3':
dependencies:
'@discordjs/collection': 2.1.1
'@discordjs/rest': 2.6.1
'@discordjs/util': 1.2.0
'@sapphire/async-queue': 1.5.5
'@types/ws': 8.18.1
'@vladfrangu/async_event_emitter': 2.4.7
discord-api-types: 0.38.47
tslib: 2.8.1
ws: 8.20.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@dotenvx/dotenvx@1.66.0':
dependencies:
commander: 11.1.0
@ -4940,17 +4646,6 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.4':
optional: true
'@sapphire/async-queue@1.5.5': {}
'@sapphire/shapeshift@4.0.0':
dependencies:
fast-deep-equal: 3.1.3
lodash: 4.18.1
'@sapphire/snowflake@3.5.3': {}
'@sapphire/snowflake@3.5.5': {}
'@sec-ant/readable-stream@0.4.1': {}
'@shikijs/core@4.1.0':
@ -5004,73 +4699,6 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@snazzah/davey-android-arm-eabi@0.1.11':
optional: true
'@snazzah/davey-android-arm64@0.1.11':
optional: true
'@snazzah/davey-darwin-arm64@0.1.11':
optional: true
'@snazzah/davey-darwin-x64@0.1.11':
optional: true
'@snazzah/davey-freebsd-x64@0.1.11':
optional: true
'@snazzah/davey-linux-arm-gnueabihf@0.1.11':
optional: true
'@snazzah/davey-linux-arm64-gnu@0.1.11':
optional: true
'@snazzah/davey-linux-arm64-musl@0.1.11':
optional: true
'@snazzah/davey-linux-x64-gnu@0.1.11':
optional: true
'@snazzah/davey-linux-x64-musl@0.1.11':
optional: true
'@snazzah/davey-wasm32-wasi@0.1.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@snazzah/davey-win32-arm64-msvc@0.1.11':
optional: true
'@snazzah/davey-win32-ia32-msvc@0.1.11':
optional: true
'@snazzah/davey-win32-x64-msvc@0.1.11':
optional: true
'@snazzah/davey@0.1.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
optionalDependencies:
'@snazzah/davey-android-arm-eabi': 0.1.11
'@snazzah/davey-android-arm64': 0.1.11
'@snazzah/davey-darwin-arm64': 0.1.11
'@snazzah/davey-darwin-x64': 0.1.11
'@snazzah/davey-freebsd-x64': 0.1.11
'@snazzah/davey-linux-arm-gnueabihf': 0.1.11
'@snazzah/davey-linux-arm64-gnu': 0.1.11
'@snazzah/davey-linux-arm64-musl': 0.1.11
'@snazzah/davey-linux-x64-gnu': 0.1.11
'@snazzah/davey-linux-x64-musl': 0.1.11
'@snazzah/davey-wasm32-wasi': 0.1.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
'@snazzah/davey-win32-arm64-msvc': 0.1.11
'@snazzah/davey-win32-ia32-msvc': 0.1.11
'@snazzah/davey-win32-x64-msvc': 0.1.11
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
'@standard-schema/spec@1.1.0': {}
'@tailwindcss/node@4.3.0':
@ -5314,8 +4942,6 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)
'@vladfrangu/async_event_emitter@2.4.7': {}
'@voidzero-dev/vite-plus-core@0.1.22(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@5.9.3)(yaml@2.9.0)':
dependencies:
'@oxc-project/runtime': 0.129.0
@ -5643,27 +5269,6 @@ snapshots:
diff@8.0.4: {}
discord-api-types@0.38.47: {}
discord.js@14.26.4:
dependencies:
'@discordjs/builders': 1.14.1
'@discordjs/collection': 1.5.3
'@discordjs/formatters': 0.6.2
'@discordjs/rest': 2.6.1
'@discordjs/util': 1.2.0
'@discordjs/ws': 1.2.3
'@sapphire/snowflake': 3.5.3
discord-api-types: 0.38.47
fast-deep-equal: 3.1.3
lodash.snakecase: 4.1.1
magic-bytes.js: 1.13.0
tslib: 2.8.1
undici: 6.24.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
dompurify@3.4.5:
optionalDependencies:
'@types/trusted-types': 2.0.7
@ -6386,10 +5991,6 @@ snapshots:
lines-and-columns@1.2.4: {}
lodash.snakecase@4.1.1: {}
lodash@4.18.1: {}
log-symbols@6.0.0:
dependencies:
chalk: 5.6.2
@ -6407,8 +6008,6 @@ snapshots:
dependencies:
react: 19.2.6
magic-bytes.js@1.13.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -6994,8 +6593,6 @@ snapshots:
openapi-types@12.1.3: {}
opusscript@0.1.1: {}
ora@8.2.0:
dependencies:
chalk: 5.6.2
@ -7145,10 +6742,6 @@ snapshots:
dependencies:
parse-ms: 4.0.0
prism-media@1.3.5(opusscript@0.1.1):
optionalDependencies:
opusscript: 0.1.1
prompts@2.4.2:
dependencies:
kleur: 3.0.3
@ -7664,8 +7257,6 @@ snapshots:
trough@2.2.0: {}
ts-mixer@6.0.4: {}
ts-morph@26.0.0:
dependencies:
'@ts-morph/common': 0.27.0
@ -7711,8 +7302,6 @@ snapshots:
undici-types@7.16.0: {}
undici@6.24.1: {}
undici@7.25.0: {}
unicorn-magic@0.3.0: {}

View file

@ -1,5 +1,7 @@
packages:
- apps/*
- apps/cli
- apps/web
- apps/workspace-backend
- packages/*
- docs
@ -9,7 +11,6 @@ allowBuilds:
catalog:
"@base-ui/react": ^1.4.1
"@discordjs/voice": ^0.19.2
"@tailwindcss/vite": ^4.1.18
"@types/node": ^24.12.4
"@types/react": ^19.2.10
@ -18,9 +19,7 @@ catalog:
"@vitejs/plugin-react": ^6.0.2
class-variance-authority: ^0.7.1
clsx: ^2.1.1
discord.js: ^14.22.1
lucide-react: ^1.14.0
opusscript: ^0.1.1
react: ^19.2.4
react-dom: ^19.2.4
shadcn: ^4.7.0