diff --git a/.github/workflows/publish-codex-flows.yml b/.github/workflows/publish-codex-flows.yml index 3ad531e..cb1b3fe 100644 --- a/.github/workflows/publish-codex-flows.yml +++ b/.github/workflows/publish-codex-flows.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e4d39ac..1524e44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/RELEASE.md b/RELEASE.md index 6409c02..fa57d26 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 ``` diff --git a/apps/discord-bridge/README.md b/apps/discord-bridge/README.md deleted file mode 100644 index 7e34b73..0000000 --- a/apps/discord-bridge/README.md +++ /dev/null @@ -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. diff --git a/apps/discord-bridge/package.json b/apps/discord-bridge/package.json deleted file mode 100644 index 8735140..0000000 --- a/apps/discord-bridge/package.json +++ /dev/null @@ -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:" - } -} diff --git a/apps/discord-bridge/scripts/build-package.ts b/apps/discord-bridge/scripts/build-package.ts deleted file mode 100644 index c6a5364..0000000 --- a/apps/discord-bridge/scripts/build-package.ts +++ /dev/null @@ -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 { - 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 { - 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): Promise { - return new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", (code) => resolve(code)); - }); -} diff --git a/apps/discord-bridge/scripts/smoke-bin.ts b/apps/discord-bridge/scripts/smoke-bin.ts deleted file mode 100644 index b6790aa..0000000 --- a/apps/discord-bridge/scripts/smoke-bin.ts +++ /dev/null @@ -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 { - 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): Promise { - return new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", (code) => resolve(code)); - }); -} diff --git a/apps/discord-bridge/src/bridge.ts b/apps/discord-bridge/src/bridge.ts deleted file mode 100644 index b8860aa..0000000 --- a/apps/discord-bridge/src/bridge.ts +++ /dev/null @@ -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 & { - transport: DiscordBridgeTransport; - backend?: undefined; - }; - -export type DiscordCodexBridgeBackendOptions = { - backend: CodexWorkspaceBackend; - transport: DiscordBridgeTransport; - logger?: DiscordBridgeLogger; - config?: Pick; - 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 { - 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 { - 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 { - 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); -} diff --git a/apps/discord-bridge/src/config.ts b/apps/discord-bridge/src/config.ts deleted file mode 100644 index 5e23b52..0000000 --- a/apps/discord-bridge/src/config.ts +++ /dev/null @@ -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([ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", -]); -const summaryValues = new Set([ - "auto", - "concise", - "detailed", - "none", -]); -const progressModeValues = new Set([ - "summary", - "commentary", - "none", -]); -const consoleOutputValues = new Set([ - "messages", - "none", -]); -const logLevelValues = new Set([ - "debug", - "info", - "warn", - "error", - "silent", -]); -const approvalPolicyValues = new Set([ - "untrusted", - "on-failure", - "on-request", - "never", -]); -const sandboxValues = new Set([ - "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 { - const flags = new Map(); - 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, - name: string, -): string | undefined { - const value = flags.get(name); - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function csvSet(value: string | undefined): Set { - return new Set( - (value ?? "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean), - ); -} - -function record(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record - : {}; -} - -function optionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function booleanFlag(flags: Map, 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, - 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(); - 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(); - 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 Discord bot token, or CODEX_DISCORD_BOT_TOKEN - --allowed-user-ids Comma-separated Discord user ids, or CODEX_DISCORD_ALLOWED_USER_IDS - -Options: - --app-server-url Existing app-server WebSocket URL - --local-app-server Start a local app-server over stdio - --state-path Persistent bridge state file - --allowed-channel-ids Comma-separated parent channel ids - --home-channel-id Enable workspace mode for one Discord home channel - --main-thread-id Resume an existing Codex operator thread for workspace mode - --workspace-forum-channel-id - Optional workbench forum channel for workspace posts - --task-threads-channel-id Optional workbench text channel for task threads - --hook-spool-dir Directory drained for Codex hook events - [dir] Optional Codex thread directory, resolved from home - --dir Codex thread directory, resolved from home - --cwd Alias for --dir - --model Codex model override - --model-provider Codex model provider override - --service-tier Codex service tier override - --effort none|minimal|low|medium|high|xhigh - --summary auto|concise|detailed|none - --progress-mode summary|commentary|none - --console-output messages|none - --log-level debug|info|warn|error|silent - --approval-policy untrusted|on-failure|on-request|never - --sandbox read-only|workspace-write|danger-full-access - --permissions-profile 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); -} diff --git a/apps/discord-bridge/src/console-output.ts b/apps/discord-bridge/src/console-output.ts deleted file mode 100644 index 7079ea7..0000000 --- a/apps/discord-bridge/src/console-output.ts +++ /dev/null @@ -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; -}; - -export type ConsoleMessageFormatOptions = { - color?: boolean; - now?: () => Date; -}; - -const resetColor = "\x1b[0m"; -const kindColors: Record = { - 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; -} diff --git a/apps/discord-bridge/src/discord-transport.ts b/apps/discord-bridge/src/discord-transport.ts deleted file mode 100644 index c3829f0..0000000 --- a/apps/discord-bridge/src/discord-transport.ts +++ /dev/null @@ -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 { - 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 { - this.#client?.destroy(); - this.#client = undefined; - } - - async registerCommands( - options: DiscordBridgeCommandRegistration = {}, - ): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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> { - const client = this.#client; - if (!client) { - throw new Error("Discord bridge is not connected"); - } - if (client.isReady()) { - return client; - } - return await new Promise>((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 { - const client = await this.#readyClient(); - const guildIds = new Set(); - 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[] { - const rows: ActionRowBuilder[] = []; - for (let index = 0; index < picker.options.length; index += 5) { - const row = new ActionRowBuilder(); - 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 { - 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; - reason?: string; -}; - -type ThreadCreatableChannel = { - id: string; - threads?: { - create(options: ThreadCreateOptions): Promise<{ id?: string }>; - }; -}; - -type SendableChannel = ThreadCreatableChannel & { - send(options: Record): Promise<{ id?: string }>; - sendTyping?: () => Promise; - members?: { - add(userId: string): Promise; - }; - messages?: { - fetch(messageId: string): Promise; - fetch(options: { - limit: number; - before?: string; - }): Promise<{ values(): IterableIterator }>; - }; -}; - -type DiscordFetchedMessage = { - id: string; - webhookId?: string | null; - delete(): Promise; - edit(options: Record): Promise; - pinned?: boolean; - pin?(): Promise; - react?(reaction: string): Promise; - 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); -} diff --git a/apps/discord-bridge/src/hook-cli.ts b/apps/discord-bridge/src/hook-cli.ts deleted file mode 100644 index b7b92e6..0000000 --- a/apps/discord-bridge/src/hook-cli.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 => 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 { - return { - hooks: [ - { - type: "command", - command, - timeout: 10, - }, - ], - }; -} - -function removeWorkspaceStopHookHandlers(input: unknown): Record | 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 { - 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 { - 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 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 Package for vp dlx. Defaults to @peezy.tech/codex-discord-bridge. - --config-path Codex config.toml path. - --hooks-path Codex hooks.json path. - --dry-run Print the planned install result without writing files. -`; -} - -function record(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? value as Record - : {}; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/apps/discord-bridge/src/index.ts b/apps/discord-bridge/src/index.ts deleted file mode 100755 index d62d54e..0000000 --- a/apps/discord-bridge/src/index.ts +++ /dev/null @@ -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 { - 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 { - 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(); diff --git a/apps/discord-bridge/src/local-workspace-backend.ts b/apps/discord-bridge/src/local-workspace-backend.ts deleted file mode 100644 index 74960e4..0000000 --- a/apps/discord-bridge/src/local-workspace-backend.ts +++ /dev/null @@ -1,4165 +0,0 @@ -import { watch, type Dirent, type FSWatcher } from "node:fs"; -import { readdir, stat } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { createHash, randomUUID } from "node:crypto"; - -import type { JsonRpcNotification, JsonRpcRequest } from "@peezy.tech/codex-flows/rpc"; -import type { JsonValue } from "@peezy.tech/codex-flows/generated/serde_json/JsonValue"; -import type { v2 } from "@peezy.tech/codex-flows/generated"; -import { - WorkspaceDelegationCapability, - type WorkspaceDelegation, - type WorkspacePendingWake, -} from "@peezy.tech/codex-flows/workspace-backend"; - -import type { DiscordConsoleOutput } from "./console-output.ts"; -import type { - CodexWorkspaceBackend, - CodexWorkspacePresenter, -} from "./workspace-backend.ts"; -import { DiscordThreadRunner, MessageDeduplicator } from "./runner.ts"; -import { - createDiscordBridgeLogger, - type DiscordBridgeLogger, -} from "./logger.ts"; -import { - archiveStopHookSpoolFile, - ensureStopHookSpool, - readPendingStopHookSpoolFiles, - stopHookSpoolPaths, -} from "./stop-hook-spool.ts"; -import type { - CodexBridgeClient, - DiscordBridgeCommandRegistration, - DiscordBridgeConfig, - DiscordWorkspaceDelegation, - DiscordWorkspaceHookEvent, - DiscordWorkspaceObservedThread, - DiscordWorkspacePendingWake, - DiscordWorkspaceSurfaceConfig, - DiscordWorkspaceWorkspaceSurface, - DiscordBridgeSession, - DiscordBridgeState, - DiscordBridgeStateStore, - DiscordClearInbound, - DiscordClearWebhooksInbound, - DiscordGoalsInbound, - DiscordInbound, - DiscordMessageInbound, - DiscordReactionInbound, - DiscordStatusInbound, - DiscordThreadPickerInbound, - DiscordThreadsInbound, - DiscordThreadStartInbound, -} from "./types.ts"; - -const maxDiscordMessageLength = 2000; -const workspaceToolsVersion = 1; -const stopHookDrainDebounceMs = 100; -const stopHookRetryMs = 1_000; -const threadPickerReactions = [ - "1️⃣", - "2️⃣", - "3️⃣", - "4️⃣", - "5️⃣", - "6️⃣", - "7️⃣", - "8️⃣", - "9️⃣", - "🔟", -]; - -type ThreadSnapshot = { - terminalTurnIds: string[]; - lastFinal?: { - turnId: string; - text: string; - }; -}; - -type WorkspaceThreadSummary = { - id: string; - title: string; - cwd: string; - status: string; - updatedAt: number; - discordThreadId?: string; -}; - -type WorkspaceThreadPicker = { - channelId: string; - authorId: string; - entries: WorkspaceThreadSummary[]; -}; - -type WorkspaceGoalSummary = WorkspaceThreadSummary & { - goal?: v2.ThreadGoal | null; - goalError?: string; -}; - -type WorkspaceGoalPicker = { - channelId: string; - authorId: string; - workspace: DiscordWorkspaceWorkspaceSurface; - entries: WorkspaceGoalSummary[]; -}; - -type WorkspaceGoalActionPicker = { - channelId: string; - authorId: string; - workspace: DiscordWorkspaceWorkspaceSurface; - entry: WorkspaceGoalSummary; -}; - -type WorkspaceSurface = DiscordWorkspaceSurfaceConfig & { - workspaceCwds?: string[]; -}; - -type WorkspaceWorkbenchConfig = { - surfaceKey: string; - workspaceForumChannelId: string; - taskThreadsChannelId: string; -}; - -const defaultWorkspaceSurfaceKey = "default"; - -export type LocalCodexWorkspaceBackendOptions = { - client: CodexBridgeClient; - presenter: CodexWorkspacePresenter; - store: DiscordBridgeStateStore; - config: DiscordBridgeConfig; - now?: () => Date; - logger?: DiscordBridgeLogger; - consoleOutput?: DiscordConsoleOutput; -}; - -export class LocalCodexWorkspaceBackend implements CodexWorkspaceBackend { - readonly client: CodexBridgeClient; - readonly presenter: CodexWorkspacePresenter; - readonly store: DiscordBridgeStateStore; - readonly config: DiscordBridgeConfig; - #state: DiscordBridgeState | undefined; - #runnersByDiscordThread = new Map(); - #runnersByCodexThread = new Map(); - #persistChain: Promise = Promise.resolve(); - #now: () => Date; - #dedupe: MessageDeduplicator; - #logger: DiscordBridgeLogger; - #consoleOutput: DiscordConsoleOutput | undefined; - #workspaceStopHookWatcher: FSWatcher | undefined; - #workspaceStopHookDrainTimer: ReturnType | undefined; - #workspaceStopHookDrainChain: Promise = Promise.resolve(); - #transportStarted = false; - #threadPickersByMessage = new Map(); - #threadPickersById = new Map(); - #goalPickersById = new Map(); - #goalActionPickersById = new Map(); - - constructor(options: LocalCodexWorkspaceBackendOptions) { - this.client = options.client; - this.presenter = options.presenter; - this.store = options.store; - this.config = options.config; - this.#now = options.now ?? (() => new Date()); - this.#dedupe = new MessageDeduplicator({ now: this.#now }); - this.#logger = options.logger ?? - createDiscordBridgeLogger({ - debug: this.config.debug, - logLevel: this.config.logLevel, - now: this.#now, - }); - this.#consoleOutput = options.consoleOutput; - } - - async start(): Promise { - this.#state = await this.store.load(); - for (const session of this.#state.sessions) { - this.#registerRunner(session); - } - this.#debug("bridge.start", { - sessions: this.#state.sessions.length, - queue: this.#state.queue.length, - deliveries: this.#state.deliveries.length, - allowedUsers: this.config.allowedUserIds.size, - allowedChannels: this.config.allowedChannelIds.size, - cwd: this.config.cwd, - summary: this.config.summary, - }); - this.client.on("notification", (message) => { - void this.#handleNotification(message).catch((error) => { - this.#debug("notification.error", { - method: message.method, - error: errorMessage(error), - }); - this.#error("notification.failed", { - method: message.method, - error: errorMessage(error), - }); - }); - }); - this.client.on("request", (message) => this.#handleServerRequest(message)); - await this.client.connect(); - this.#debug("client.connected"); - await this.#ensureWorkspaceSession(); - } - - async startTransportDependentWork(): Promise { - this.#transportStarted = true; - this.#debug("transport.started"); - await this.#reconcileWorkspaceWorkbench(); - } - - async startBackgroundWork(): Promise { - for (const runner of this.#runnersByDiscordThread.values()) { - if (this.#shouldAutoStartRunner(runner.session)) { - runner.start(); - } - } - await this.#startWorkspaceStopHookSpool(); - } - - async stop(): Promise { - this.#debug("bridge.stop", { - runners: this.#runnersByDiscordThread.size, - }); - if (this.#workspaceStopHookDrainTimer) { - clearTimeout(this.#workspaceStopHookDrainTimer); - this.#workspaceStopHookDrainTimer = undefined; - } - if (this.#workspaceStopHookWatcher) { - this.#workspaceStopHookWatcher.close(); - this.#workspaceStopHookWatcher = undefined; - } - await Promise.all( - [...this.#runnersByDiscordThread.values()].map((runner) => runner.stop()), - ); - await this.#workspaceStopHookDrainChain.catch(() => undefined); - await this.#persistChain.catch(() => undefined); - this.#transportStarted = false; - this.client.close(); - } - - stateForTest(): DiscordBridgeState { - return structuredClone(this.#requireState()); - } - - async flushSummariesForTest(): Promise { - await Promise.all( - [...this.#runnersByDiscordThread.values()].map((runner) => - runner.flushSummariesForTest() - ), - ); - } - - commandRegistration(): DiscordBridgeCommandRegistration { - return { channelIds: this.#commandRegistrationChannelIds() }; - } - - async handleInbound(inbound: DiscordInbound): Promise { - await this.#handleInbound(inbound); - } - - async #handleInbound(inbound: DiscordInbound): Promise { - this.#debug("inbound.received", { - kind: inbound.kind, - channelId: inbound.channelId, - authorId: inbound.author.id, - isBot: inbound.author.isBot, - messageId: inbound.kind === "message" ? inbound.messageId : undefined, - sourceMessageId: inbound.kind === "threadStart" ? inbound.sourceMessageId : undefined, - contentLength: inbound.kind === "message" - ? inbound.content.length - : inbound.kind === "threadStart" - ? inbound.prompt?.length - : undefined, - mentionedUserIds: inbound.kind === "threadStart" - ? inbound.mentionedUserIds?.length - : undefined, - }); - if (inbound.author.isBot) { - this.#debug("inbound.ignored.bot", { - kind: inbound.kind, - channelId: inbound.channelId, - authorId: inbound.author.id, - }); - return; - } - - if (inbound.kind === "clear") { - await this.#handleClear(inbound); - return; - } - if (inbound.kind === "clearWebhooks") { - await this.#handleClearWebhooks(inbound); - return; - } - if (inbound.kind === "status") { - await this.#handleStatusCommand(inbound); - return; - } - if (inbound.kind === "threads") { - await this.#handleThreadsCommand(inbound); - return; - } - if (inbound.kind === "goals") { - await this.#handleGoalsCommand(inbound); - return; - } - if (inbound.kind === "threadPicker") { - await this.#handleThreadPickerSelection(inbound); - return; - } - if (inbound.kind === "reaction") { - await this.#handleThreadPickerReaction(inbound); - return; - } - - if (inbound.kind === "threadStart") { - if (this.#workspaceSurfaceForHomeChannel(inbound.channelId)) { - await this.#handleWorkspaceThreadStart(inbound); - return; - } - if (!this.config.allowedUserIds.has(inbound.author.id)) { - this.#debug("threadStart.ignored.user", { - channelId: inbound.channelId, - authorId: inbound.author.id, - }); - return; - } - if (!this.#isAllowedInboundChannel(inbound)) { - this.#debug("threadStart.ignored.channel", { - channelId: inbound.channelId, - }); - return; - } - await this.#handleThreadStart(inbound); - return; - } - await this.#handleMessage(inbound); - } - - async #handleClear(command: DiscordClearInbound): Promise { - if (!this.config.allowedUserIds.has(command.author.id)) { - this.#debug("clear.ignored.user", { - channelId: command.channelId, - authorId: command.author.id, - }); - await command.reply?.("Only globally allowed Discord users can clear bridge threads."); - return; - } - if (!this.presenter.deleteThread) { - this.#debug("clear.unsupported", { channelId: command.channelId }); - await command.reply?.("This workspace presenter cannot delete threads."); - return; - } - const state = this.#requireState(); - const scopedSessions = state.sessions.filter((session) => - this.#isSessionInClearScope(session, command) - ); - const inactive = scopedSessions.filter((session) => - !this.#isSessionRunning(session, state) - ); - const runningCount = scopedSessions.length - inactive.length; - const deletedThreadIds: string[] = []; - const failed: Array<{ threadId: string; error: string }> = []; - this.#debug("clear.start", { - channelId: command.channelId, - guildId: command.guildId, - scoped: scopedSessions.length, - inactive: inactive.length, - running: runningCount, - }); - for (const session of inactive) { - try { - await this.presenter.deleteThread(session.discordThreadId); - await this.#deleteSourceMessage(session); - deletedThreadIds.push(session.discordThreadId); - const runner = this.#runnersByDiscordThread.get(session.discordThreadId); - await runner?.stop(); - this.#runnersByDiscordThread.delete(session.discordThreadId); - this.#runnersByCodexThread.delete(session.codexThreadId); - this.#debug("clear.threadDeleted", { - discordThreadId: session.discordThreadId, - codexThreadId: session.codexThreadId, - }); - } catch (error) { - const message = errorMessage(error); - failed.push({ threadId: session.discordThreadId, error: message }); - this.#debug("clear.threadDeleteFailed", { - discordThreadId: session.discordThreadId, - codexThreadId: session.codexThreadId, - error: message, - }); - } - } - if (deletedThreadIds.length > 0) { - const deleted = new Set(deletedThreadIds); - state.sessions = state.sessions.filter( - (session) => !deleted.has(session.discordThreadId), - ); - state.queue = state.queue.filter( - (item) => !deleted.has(item.discordThreadId), - ); - state.activeTurns = state.activeTurns.filter( - (active) => !deleted.has(active.discordThreadId), - ); - state.deliveries = state.deliveries.filter( - (delivery) => !deleted.has(delivery.discordThreadId), - ); - await this.#persist(); - } - await command.reply?.(clearSummary({ - deleted: deletedThreadIds.length, - running: runningCount, - failed: failed.length, - })); - } - - async #handleClearWebhooks(command: DiscordClearWebhooksInbound): Promise { - if (!this.config.allowedUserIds.has(command.author.id)) { - this.#debug("clearWebhooks.ignored.user", { - channelId: command.channelId, - authorId: command.author.id, - }); - await command.reply?.( - "Only globally allowed Discord users can clear webhook messages.", - ); - return; - } - if (!this.presenter.deleteWebhookMessages) { - this.#debug("clearWebhooks.unsupported", { channelId: command.channelId }); - await command.reply?.("This workspace presenter cannot delete webhook messages."); - return; - } - this.#debug("clearWebhooks.start", { - channelId: command.channelId, - guildId: command.guildId, - filtered: Boolean(command.webhookUrl), - }); - let result: { deleted: number; failed: number }; - try { - result = await this.presenter.deleteWebhookMessages(command.channelId, { - webhookUrl: command.webhookUrl, - }); - } catch (error) { - const message = errorMessage(error); - this.#debug("clearWebhooks.failed", { - channelId: command.channelId, - error: message, - }); - await command.reply?.(`Failed to clear webhook messages: ${message}`); - return; - } - this.#debug("clearWebhooks.complete", { - channelId: command.channelId, - deleted: result.deleted, - failed: result.failed, - }); - await command.reply?.(clearWebhooksSummary(result)); - } - - async #handleStatusCommand(command: DiscordStatusInbound): Promise { - if (!this.config.allowedUserIds.has(command.author.id)) { - this.#debug("status.ignored.user", { - channelId: command.channelId, - authorId: command.author.id, - }); - await command.reply?.("Only globally allowed Discord users can read workspace status."); - return; - } - if (!this.#isAllowedChannel(command.channelId)) { - this.#debug("status.ignored.channel", { channelId: command.channelId }); - await command.reply?.("This Discord channel is not allowed for the bridge."); - return; - } - const surface = this.#workspaceSurfaceForChannel(command.channelId) ?? - this.#primaryWorkspaceSurface(); - const workbench = this.#workspaceWorkbenchConfig(surface); - const activeThreads = await this.#listActiveCodexThreadSummaries(surface); - const openableThreads = activeThreads.filter((thread) => - !thread.discordThreadId && - !this.#isWorkspaceMainThread(thread.id) && - Boolean(workbench) - ).slice(0, threadPickerReactions.length); - const statusText = this.#workspaceStatusMessage({ - activeThreads, - openableThreads, - }, surface); - if (openableThreads.length === 0 || !command.replyPicker) { - await command.reply?.(statusText); - return; - } - const pickerId = `status-${randomUUID()}`; - this.#threadPickersById.set(pickerId, { - channelId: command.channelId, - authorId: command.author.id, - entries: openableThreads, - }); - try { - await command.replyPicker({ - pickerId, - text: statusText, - options: openableThreads.map((_, index) => ({ - id: String(index), - label: String(index + 1), - })), - }); - } catch (error) { - this.#threadPickersById.delete(pickerId); - await command.reply?.( - `Failed to send active-thread picker: ${errorMessage(error)}`, - ); - } - } - - async #handleThreadsCommand(command: DiscordThreadsInbound): Promise { - if (!this.config.allowedUserIds.has(command.author.id)) { - this.#debug("threads.ignored.user", { - channelId: command.channelId, - authorId: command.author.id, - }); - await command.reply?.("Only globally allowed Discord users can list workspace threads."); - return; - } - if (!this.#isAllowedChannel(command.channelId)) { - this.#debug("threads.ignored.channel", { channelId: command.channelId }); - await command.reply?.("This Discord channel is not allowed for the bridge."); - return; - } - const workspace = this.#workspaceForChannel(command.channelId); - if (!workspace) { - await command.reply?.("Run `/threads` in a workspace forum post or opened workspace thread."); - return; - } - const threads = await this.#listWorkspaceThreads(workspace); - if (threads.length === 0) { - await command.reply?.(`No Codex threads found for ${workspace.title}.`); - return; - } - if (!command.replyPicker) { - await command.reply?.( - "This workspace presenter cannot send ephemeral thread pickers.", - ); - return; - } - const entries = threads.slice(0, threadPickerReactions.length); - const pickerId = `threads-${randomUUID()}`; - this.#threadPickersById.set(pickerId, { - channelId: command.channelId, - authorId: command.author.id, - entries, - }); - try { - await command.replyPicker({ - pickerId, - text: threadPickerText(workspace, entries, threads.length, { - action: "Choose a number to open or resume that thread in Discord.", - }), - options: entries.map((_, index) => ({ - id: String(index), - label: String(index + 1), - })), - }); - } catch (error) { - this.#threadPickersById.delete(pickerId); - await command.reply?.( - `Failed to send the ephemeral thread picker: ${errorMessage(error)}`, - ); - return; - } - } - - async #handleGoalsCommand(command: DiscordGoalsInbound): Promise { - if (!this.config.allowedUserIds.has(command.author.id)) { - this.#debug("goals.ignored.user", { - channelId: command.channelId, - authorId: command.author.id, - }); - await command.reply?.("Only globally allowed Discord users can manage goals."); - return; - } - if (!this.#isAllowedChannel(command.channelId)) { - this.#debug("goals.ignored.channel", { channelId: command.channelId }); - await command.reply?.("This Discord channel is not allowed for the bridge."); - return; - } - const session = this.#sessionForDiscordThread(command.channelId); - if (session) { - await this.#handleThreadGoalsCommand(command, session); - return; - } - const workspace = this.#workspaceForumForChannel(command.channelId); - if (!workspace) { - await command.reply?.( - "Run `/goals` in a workspace forum post or opened Codex thread.", - ); - return; - } - if (!command.replyPicker) { - await command.reply?.( - "This workspace presenter cannot send ephemeral goal pickers.", - ); - return; - } - const entries = await this.#listWorkspaceGoalSummaries(workspace); - if (entries.length === 0) { - await command.reply?.(`No Codex threads found for ${workspace.title}.`); - return; - } - const pickerEntries = entries.slice(0, threadPickerReactions.length); - const pickerId = `goals-${randomUUID()}`; - this.#goalPickersById.set(pickerId, { - channelId: command.channelId, - authorId: command.author.id, - workspace, - entries: pickerEntries, - }); - try { - await command.replyPicker({ - pickerId, - text: goalPickerText(workspace, pickerEntries, entries.length), - options: pickerEntries.map((_, index) => ({ - id: String(index), - label: String(index + 1), - })), - }); - } catch (error) { - this.#goalPickersById.delete(pickerId); - await command.reply?.( - `Failed to send the goal picker: ${errorMessage(error)}`, - ); - } - } - - async #handleThreadGoalsCommand( - command: DiscordGoalsInbound, - session: DiscordBridgeSession, - ): Promise { - const hasMutation = hasGoalMutation(command); - if (command.clear && hasMutation) { - await command.reply?.("Use either `clear` or goal updates, not both."); - return; - } - const workspace = this.#workspaceForGoalSession(session); - const picker = { - channelId: command.channelId, - authorId: command.author.id, - workspace, - }; - if (command.clear) { - try { - await this.client.clearThreadGoal({ threadId: session.codexThreadId }); - await command.reply?.(`Cleared goal for ${session.title}.`); - } catch (error) { - await command.reply?.( - `Failed to clear goal for ${session.title}: ${errorMessage(error)}`, - ); - } - return; - } - if (hasMutation) { - try { - const response = await this.client.setThreadGoal({ - threadId: session.codexThreadId, - objective: command.objective, - status: command.goalStatus, - tokenBudget: command.tokenBudget, - }); - await this.#showGoalActionPicker( - command, - picker, - this.#goalSummaryFromSession(session, { goal: response.goal }), - { prefix: command.objective ? "Saved goal." : "Updated goal." }, - ); - } catch (error) { - await command.reply?.( - `Failed to update goal for ${session.title}: ${errorMessage(error)}`, - ); - } - return; - } - const entry = await this.#goalSummaryForSession(session); - await this.#showGoalActionPicker(command, picker, entry); - } - - async #handleThreadPickerSelection( - selection: DiscordThreadPickerInbound, - ): Promise { - if (!this.config.allowedUserIds.has(selection.author.id)) { - return; - } - const picker = this.#threadPickersById.get(selection.pickerId); - if (picker) { - await this.#handleWorkspaceThreadPickerSelection(selection, picker); - return; - } - const goalPicker = this.#goalPickersById.get(selection.pickerId); - if (goalPicker) { - await this.#handleGoalPickerSelection(selection, goalPicker); - return; - } - const goalActionPicker = this.#goalActionPickersById.get(selection.pickerId); - if (goalActionPicker) { - await this.#handleGoalActionSelection(selection, goalActionPicker); - return; - } - await selection.update?.("This picker is no longer active."); - } - - async #handleWorkspaceThreadPickerSelection( - selection: DiscordThreadPickerInbound, - picker: WorkspaceThreadPicker, - ): Promise { - if (selection.author.id !== picker.authorId) { - await selection.reply?.("Only the user who ran the command can use this picker."); - return; - } - const index = Number.parseInt(selection.optionId, 10); - const entry = Number.isInteger(index) ? picker.entries[index] : undefined; - if (!entry) { - await selection.update?.("That thread choice is no longer available."); - return; - } - this.#threadPickersById.delete(selection.pickerId); - try { - const session = await this.#materializeWorkspaceThread(entry.id, { - author: selection.author, - surface: this.#workspaceSurfaceForChannel(picker.channelId), - }); - await updateOrReply( - selection, - `Opened ${session.title}: <#${session.discordThreadId}>`, - ); - } catch (error) { - this.#error("threads.picker.openFailed", { - channelId: selection.channelId, - pickerId: selection.pickerId, - threadId: entry.id, - error: errorMessage(error), - }); - await updateOrReply( - selection, - `Failed to open ${entry.title}: ${errorMessage(error)}`, - ); - } - } - - async #handleGoalPickerSelection( - selection: DiscordThreadPickerInbound, - picker: WorkspaceGoalPicker, - ): Promise { - if (selection.author.id !== picker.authorId) { - await selection.reply?.("Only the user who ran `/goals` can use this picker."); - return; - } - const index = Number.parseInt(selection.optionId, 10); - const entry = Number.isInteger(index) ? picker.entries[index] : undefined; - if (!entry) { - await selection.update?.("That goal choice is no longer available."); - return; - } - this.#goalPickersById.delete(selection.pickerId); - await this.#showGoalActionPicker(selection, picker, entry); - } - - async #handleGoalActionSelection( - selection: DiscordThreadPickerInbound, - picker: WorkspaceGoalActionPicker, - ): Promise { - if (selection.author.id !== picker.authorId) { - await selection.reply?.("Only the user who ran `/goals` can use this picker."); - return; - } - const action = selection.optionId; - this.#goalActionPickersById.delete(selection.pickerId); - if (action === "open") { - try { - const session = await this.#materializeWorkspaceThread(picker.entry.id, { - author: selection.author, - surface: this.#workspaceSurfaceForWorkspace(picker.workspace), - }); - const updatedEntry = { - ...picker.entry, - discordThreadId: session.discordThreadId, - }; - await this.#showGoalActionPicker(selection, picker, updatedEntry, { - prefix: `Opened ${session.title}: <#${session.discordThreadId}>`, - }); - } catch (error) { - await updateOrReply( - selection, - `Failed to open ${picker.entry.title}: ${errorMessage(error)}`, - ); - } - return; - } - if (action === "clear") { - try { - await this.client.clearThreadGoal({ threadId: picker.entry.id }); - await updateOrReply( - selection, - `Cleared goal for ${picker.entry.title}.`, - ); - } catch (error) { - await updateOrReply( - selection, - `Failed to clear goal for ${picker.entry.title}: ${errorMessage(error)}`, - ); - } - return; - } - const status = action.startsWith("status:") - ? action.slice("status:".length) - : ""; - if ( - status === "active" || - status === "paused" || - status === "budgetLimited" || - status === "complete" - ) { - try { - const response = await this.client.setThreadGoal({ - threadId: picker.entry.id, - status, - }); - await this.#showGoalActionPicker( - selection, - picker, - { ...picker.entry, goal: response.goal }, - { prefix: `Set goal status to ${status}.` }, - ); - } catch (error) { - await updateOrReply( - selection, - `Failed to update goal for ${picker.entry.title}: ${errorMessage(error)}`, - ); - } - return; - } - await selection.update?.("That goal action is no longer available."); - } - - async #showGoalActionPicker( - selection: Pick< - DiscordThreadPickerInbound, - "update" | "updatePicker" | "reply" - > & Pick, - picker: Pick, - entry: WorkspaceGoalSummary, - options: { prefix?: string } = {}, - ): Promise { - const actions = goalActionOptions(entry); - const text = goalActionText(picker.workspace, entry, options); - const sendPicker = selection.updatePicker ?? selection.replyPicker; - if (actions.length === 0 || !sendPicker) { - await updateOrReply(selection, text); - return; - } - const pickerId = `goal-actions-${randomUUID()}`; - this.#goalActionPickersById.set(pickerId, { - channelId: picker.channelId, - authorId: picker.authorId, - workspace: picker.workspace, - entry, - }); - await sendPicker({ - pickerId, - text, - options: actions, - }); - } - - async #handleThreadPickerReaction(reaction: DiscordReactionInbound): Promise { - if (!this.config.allowedUserIds.has(reaction.author.id)) { - return; - } - const pickerKey = threadPickerKey(reaction.channelId, reaction.messageId); - const picker = this.#threadPickersByMessage.get(pickerKey); - if (!picker) { - return; - } - if (reaction.author.id !== picker.authorId) { - return; - } - const index = threadPickerReactionIndex(reaction.emoji); - const entry = index === undefined ? undefined : picker.entries[index]; - if (!entry) { - return; - } - this.#threadPickersByMessage.delete(pickerKey); - try { - const session = await this.#materializeWorkspaceThread(entry.id, { - author: reaction.author, - surface: this.#workspaceSurfaceForChannel(picker.channelId), - }); - await this.presenter.sendMessage( - picker.channelId, - `Opened ${session.title}: <#${session.discordThreadId}>`, - ); - } catch (error) { - this.#error("threads.reaction.openFailed", { - channelId: reaction.channelId, - messageId: reaction.messageId, - threadId: entry.id, - error: errorMessage(error), - }); - await this.presenter.sendMessage( - picker.channelId, - `Failed to open ${entry.title}: ${errorMessage(error)}`, - ); - } - } - - async #handleThreadStart(start: DiscordThreadStartInbound): Promise { - const state = this.#requireState(); - if ( - this.#dedupe.isDuplicate(start.sourceMessageId) || - isDuplicate(state, start.sourceMessageId) - ) { - this.#debug("threadStart.ignored.duplicate", { - channelId: start.channelId, - sourceMessageId: start.sourceMessageId, - }); - return; - } - const participantUserIds = normalizeParticipantUserIds( - start.mentionedUserIds, - start.author.id, - ); - const intent = parseThreadStartIntent(threadPrompt(start)); - if (intent.kind === "invalid") { - await start.reply?.(intent.message); - this.#debug("threadStart.ignored.invalidIntent", { - channelId: start.channelId, - sourceMessageId: start.sourceMessageId, - message: intent.message, - }); - return; - } - const title = intent.kind === "resume" - ? resumeThreadTitle(start, intent.codexThreadId) - : threadTitle(start, intent.prompt); - this.#debug("threadStart.start", { - channelId: start.channelId, - sourceMessageId: start.sourceMessageId, - title, - intent: intent.kind, - cwd: intent.cwd, - hasPrompt: intent.kind === "new" && Boolean(intent.prompt), - participantUserIds, - }); - const discordThreadId = await this.presenter.createThread( - start.channelId, - title, - start.sourceMessageId, - ); - this.#debug("discord.thread.created", { - parentChannelId: start.channelId, - discordThreadId, - title, - }); - const started = intent.kind === "resume" - ? await this.client.resumeThread(this.#threadResumeParams(intent.codexThreadId, intent.cwd)) - : await this.client.startThread(this.#threadStartParams(intent.cwd)); - const codexThreadId = started.thread.id; - if (intent.kind === "new") { - await this.client.setThreadName({ - threadId: codexThreadId, - name: `[discord] ${title}`, - }); - } - const sessionCwd = intent.kind === "resume" - ? intent.cwd ?? resumeResponseCwd(started) - : intent.cwd; - const session: DiscordBridgeSession = { - discordThreadId, - parentChannelId: start.channelId, - guildId: start.guildId, - sourceMessageId: start.sourceMessageId, - codexThreadId, - title, - createdAt: this.#now().toISOString(), - ownerUserId: start.author.id, - participantUserIds, - cwd: sessionCwd, - mode: intent.kind === "resume" ? "resumed" : "new", - }; - await this.#addThreadMembers(discordThreadId, participantUserIds); - state.sessions.push(session); - const runner = this.#registerRunner(session); - await this.#persist(); - await runner.ensureStatusMessage(); - await start.reply?.(`${intent.kind === "resume" ? "Resumed" : "Started"} Codex thread ${compactId(codexThreadId)} in <#${discordThreadId}>.`); - this.#debug("threadStart.acknowledged", { - discordThreadId, - codexThreadId, - }); - - if (intent.kind === "resume") { - const snapshot = mergeThreadSnapshots( - await this.#readThreadSnapshot(codexThreadId), - threadSnapshotFromThread(started.thread), - ); - const outboundMessageIds = snapshot.lastFinal - ? await this.presenter.sendMessage(discordThreadId, snapshot.lastFinal.text) - : await this.presenter.sendMessage( - discordThreadId, - "No final assistant message found for this Codex thread.", - ); - this.#recordResumeHistoryDeliveries( - session, - start.sourceMessageId, - snapshot, - outboundMessageIds, - ); - await this.#persist(); - if (snapshot.lastFinal) { - this.#debug("threadStart.resumeFinalReplayed", { - discordThreadId, - codexThreadId, - turnId: snapshot.lastFinal.turnId, - outboundMessageIds, - terminalTurns: snapshot.terminalTurnIds.length, - }); - } else { - this.#debug("threadStart.resumeFinalMissing", { - discordThreadId, - codexThreadId, - terminalTurns: snapshot.terminalTurnIds.length, - }); - } - runner.start(); - return; - } - - if (intent.prompt) { - this.#debug("threadStart.enqueuePrompt", { - discordThreadId, - codexThreadId, - promptLength: intent.prompt.length, - }); - await runner.enqueueMessage({ - kind: "message", - channelId: discordThreadId, - messageId: start.sourceMessageId, - author: start.author, - content: intent.prompt, - createdAt: start.createdAt, - }); - } else { - runner.start(); - } - } - - async #handleMessage(message: DiscordMessageInbound): Promise { - if (this.#dedupe.isDuplicate(message.messageId)) { - this.#debug("message.ignored.rawDuplicate", { - channelId: message.channelId, - messageId: message.messageId, - }); - return; - } - if (this.#workspaceSurfaceForHomeChannel(message.channelId)) { - await this.#handleWorkspaceMessage(message); - return; - } - const runner = this.#runnersByDiscordThread.get(message.channelId); - if (!runner) { - this.#debug("message.ignored.noSession", { - channelId: message.channelId, - messageId: message.messageId, - }); - return; - } - if (!this.#isAllowedInboundChannel(message)) { - this.#debug("message.ignored.channel", { - channelId: message.channelId, - messageId: message.messageId, - }); - return; - } - if (!this.#isAllowedSessionUser(runner.session, message.author.id)) { - this.#debug("message.ignored.user", { - channelId: message.channelId, - messageId: message.messageId, - authorId: message.author.id, - ownerUserId: runner.session.ownerUserId, - participantUserIds: runner.session.participantUserIds, - }); - return; - } - await runner.enqueueMessage(message); - } - - async #handleWorkspaceThreadStart(start: DiscordThreadStartInbound): Promise { - await this.#handleWorkspaceMessage({ - kind: "message", - channelId: start.channelId, - guildId: start.guildId, - messageId: start.sourceMessageId, - author: start.author, - content: threadPrompt(start), - createdAt: start.createdAt, - }); - } - - async #handleWorkspaceMessage(message: DiscordMessageInbound): Promise { - if (!this.config.allowedUserIds.has(message.author.id)) { - this.#debug("workspace.message.ignored.user", { - channelId: message.channelId, - messageId: message.messageId, - authorId: message.author.id, - }); - return; - } - const runner = this.#workspaceRunner(); - if (!runner) { - this.#debug("workspace.message.ignored.noSession", { - channelId: message.channelId, - messageId: message.messageId, - }); - return; - } - await runner.enqueueMessage(message); - } - - #workspaceStatusMessage( - options: { - activeThreads?: WorkspaceThreadSummary[]; - openableThreads?: WorkspaceThreadSummary[]; - } = {}, - surface: WorkspaceSurface | undefined = this.#primaryWorkspaceSurface(), - ): string { - const state = this.#requireState(); - const workspace = state.workspace; - const session = this.#workspaceSession(); - const delegations = (workspace?.delegations ?? []).filter((delegation) => - this.#workspaceSurfaceForDelegation(delegation)?.key === surface?.key - ); - const workspaces = (workspace?.workspaces ?? []).filter((workspace) => - this.#workspaceSurfaceForWorkspace(workspace)?.key === surface?.key - ); - const activeDelegations = delegations.filter((delegation) => - delegation.status === "active" - ); - const workbench = this.#workspaceWorkbenchConfig(surface); - const activeThreads = options.activeThreads ?? []; - const openableThreads = options.openableThreads ?? []; - return [ - "**Codex Workspace**", - surface ? `Surface: \`${surface.key}\`` : undefined, - `Home channel: \`${surface?.homeChannelId ?? this.config.workspace?.homeChannelId ?? "disabled"}\``, - `Main thread: \`${session?.codexThreadId ?? workspace?.mainThreadId ?? "none"}\``, - `Dir: \`${session?.cwd ?? this.config.cwd ?? "default"}\``, - `Legacy thread bridge: \`enabled\``, - `Delegations: ${delegations.length} tracked, ${activeDelegations.length} active`, - "", - "**Delegation Backend**", - `Status: ${state.workspace?.toolsVersion === workspaceToolsVersion ? "privileged workspace tools available to the main Codex operator thread" : "waiting for a tool-enabled main Codex operator thread"}.`, - "", - "**Workbench**", - workbench - ? `Status: enabled; workspace forum <#${workbench.workspaceForumChannelId}>, task threads <#${workbench.taskThreadsChannelId}>` - : "Status: disabled", - `Workspaces: ${workspaces.length} tracked`, - "", - "**Active Codex Threads**", - activeThreads.length > 0 - ? activeThreadStatusLines(activeThreads, openableThreads).join("\n") - : "None", - openableThreads.length > 0 - ? "Choose a number to create or reuse a Discord task thread." - : undefined, - ].filter((line): line is string => line !== undefined).join("\n"); - } - - async #handleNotification(message: JsonRpcNotification): Promise { - if (!this.#transportStarted) { - this.#debug("notification.ignored.transportNotStarted", { - method: message.method, - }); - return; - } - const params = record(message.params); - const threadId = stringValue(params.threadId); - if (!threadId) { - this.#debug("notification.ignored.missingThread", { - method: message.method, - }); - return; - } - const runner = this.#runnersByCodexThread.get(threadId); - if (!runner) { - this.#debug("notification.ignored.noRunner", { - method: message.method, - threadId, - }); - return; - } - await runner.handleNotification(message); - if (message.method === "turn/completed" && this.#isWorkspaceMainThread(threadId)) { - await this.#processPendingWakes(); - await this.#persist(); - } - } - - #handleServerRequest(message: JsonRpcRequest): void { - if (message.method === "item/tool/call") { - void this.#handleDynamicToolCall(message).catch((error) => { - this.client.respondError( - message.id, - -32603, - errorMessage(error), - ); - }); - return; - } - this.client.respondError(message.id, -32603, "Unsupported app-server request"); - } - - async #handleDynamicToolCall(message: JsonRpcRequest): Promise { - const params = record(message.params); - const threadId = stringValue(params.threadId); - const namespace = stringValue(params.namespace); - const tool = stringValue(params.tool); - if ( - !threadId || - threadId !== this.#workspaceSession()?.codexThreadId || - namespace !== "codex_workspace" || - !tool - ) { - this.client.respondError( - message.id, - -32601, - "Unknown dynamic tool request", - ); - return; - } - const result = await this.#callWorkspaceTool(tool, record(params.arguments)); - this.client.respond(message.id, { - contentItems: [ - { - type: "inputText", - text: JSON.stringify(result, null, 2), - }, - ], - success: true, - }); - } - - async #callWorkspaceTool( - tool: string, - args: Record, - ): Promise { - if (tool === "list_delegations") { - return { - delegations: this.#workspaceDelegations(), - }; - } - if (tool === "start_delegation") { - return await this.#startDelegation(args); - } - if (tool === "resume_delegation") { - return await this.#resumeDelegation(args); - } - if (tool === "send_delegation") { - return await this.#sendDelegation(args); - } - if (tool === "read_delegation") { - return await this.#readDelegation(args); - } - if (tool === "set_delegation_policy") { - return await this.#setDelegationPolicy(args); - } - if (tool === "flush_delegation_results") { - return await this.#flushDelegationResults(args); - } - if (tool === "list_delegation_groups") { - return { - groups: this.#delegationGroups(), - }; - } - throw new Error(`Unknown workspace tool: ${tool}`); - } - - #registerRunner(session: DiscordBridgeSession): DiscordThreadRunner { - const existing = this.#runnersByDiscordThread.get(session.discordThreadId); - if (existing) { - return existing; - } - const runner = new DiscordThreadRunner(session, { - client: this.client, - presenter: this.presenter, - config: this.config, - getState: () => this.#requireState(), - persist: () => this.#persist(), - now: () => this.#now(), - debug: (event, fields = {}) => this.#debug(event, fields), - consoleOutput: this.#consoleOutput, - }); - this.#runnersByDiscordThread.set(session.discordThreadId, runner); - this.#runnersByCodexThread.set(session.codexThreadId, runner); - return runner; - } - - async #ensureWorkspaceSession(): Promise { - const workspaceConfig = this.config.workspace; - if (!workspaceConfig) { - return; - } - const state = this.#requireState(); - const existing = this.#workspaceSession(); - const explicitMainThread = Boolean(workspaceConfig.mainThreadId); - let forceCreateWorkspaceThread = false; - const shouldReuseExisting = - explicitMainThread || - state.workspace?.toolsVersion === workspaceToolsVersion; - if (existing && shouldReuseExisting) { - try { - const workspaceCwd = this.config.cwd ?? existing.cwd; - const resumed = await this.client.resumeThread(this.#threadResumeParams( - existing.codexThreadId, - workspaceCwd, - )); - const primarySurface = this.#primaryWorkspaceSurface(); - this.#runnersByDiscordThread.delete(existing.discordThreadId); - this.#runnersByCodexThread.delete(existing.codexThreadId); - existing.discordThreadId = workspaceConfig.homeChannelId; - existing.parentChannelId = workspaceConfig.homeChannelId; - existing.mode = "operator"; - existing.surfaceKey = primarySurface?.key; - existing.cwd = workspaceCwd ?? resumeResponseCwd(resumed) ?? existing.cwd; - state.workspace = { - homeChannelId: workspaceConfig.homeChannelId, - mainThreadId: existing.codexThreadId, - statusMessageId: existing.statusMessageId, - createdAt: existing.createdAt, - toolsVersion: state.workspace?.toolsVersion, - delegations: state.workspace?.delegations ?? [], - workspaces: state.workspace?.workspaces ?? [], - observedThreads: state.workspace?.observedThreads ?? [], - pendingWakes: state.workspace?.pendingWakes ?? [], - processedHookEventIds: state.workspace?.processedHookEventIds ?? [], - processedStopHookEventIds: state.workspace?.processedStopHookEventIds ?? [], - }; - this.#registerRunner(existing); - await this.#persist(); - return; - } catch (error) { - if (explicitMainThread) { - throw error; - } - forceCreateWorkspaceThread = true; - this.#debug("workspace.session.recreateAfterResumeFailure", { - codexThreadId: existing.codexThreadId, - error: errorMessage(error), - }); - } - } - if (existing) { - state.sessions = state.sessions.filter((session) => session !== existing); - this.#runnersByDiscordThread.delete(existing.discordThreadId); - this.#runnersByCodexThread.delete(existing.codexThreadId); - } - - const configuredThreadId = - forceCreateWorkspaceThread - ? undefined - : workspaceConfig.mainThreadId ?? - (state.workspace?.toolsVersion === workspaceToolsVersion - ? state.workspace.mainThreadId - : undefined); - const title = "Codex Workspace"; - const started = configuredThreadId - ? await this.client.resumeThread(this.#threadResumeParams( - configuredThreadId, - this.config.cwd, - )) - : await this.client.startThread({ - ...this.#threadStartParams(this.config.cwd), - dynamicTools: workspaceToolSpecs(), - }); - const codexThreadId = started.thread.id; - if (!configuredThreadId) { - await this.client.setThreadName({ - threadId: codexThreadId, - name: "[discord-workspace] Codex Workspace", - }); - } - const session: DiscordBridgeSession = { - discordThreadId: workspaceConfig.homeChannelId, - parentChannelId: workspaceConfig.homeChannelId, - codexThreadId, - title, - createdAt: this.#now().toISOString(), - cwd: resumeResponseCwd(started) ?? this.config.cwd, - mode: "operator", - surfaceKey: this.#primaryWorkspaceSurface()?.key, - }; - state.workspace = { - homeChannelId: workspaceConfig.homeChannelId, - mainThreadId: codexThreadId, - createdAt: session.createdAt, - toolsVersion: configuredThreadId - ? state.workspace?.toolsVersion - : workspaceToolsVersion, - delegations: state.workspace?.delegations ?? [], - workspaces: state.workspace?.workspaces ?? [], - observedThreads: state.workspace?.observedThreads ?? [], - pendingWakes: state.workspace?.pendingWakes ?? [], - processedHookEventIds: state.workspace?.processedHookEventIds ?? [], - processedStopHookEventIds: state.workspace?.processedStopHookEventIds ?? [], - }; - state.sessions.push(session); - this.#registerRunner(session); - await this.#persist(); - this.#debug("workspace.session.ready", { - homeChannelId: workspaceConfig.homeChannelId, - codexThreadId, - resumed: Boolean(configuredThreadId), - }); - } - - #workspaceSurfaces(): WorkspaceSurface[] { - const workspace = this.config.workspace; - if (!workspace) { - return []; - } - if (workspace.surfaces?.length) { - return workspace.surfaces.map((surface) => ({ - ...surface, - workspaceCwds: surface.workspaceCwds?.map((cwd) => - workspaceCwdForPath(cwd, this.config.cwd) - ), - })); - } - return [ - { - key: defaultWorkspaceSurfaceKey, - homeChannelId: workspace.homeChannelId, - workspaceForumChannelId: workspace.workspaceForumChannelId, - taskThreadsChannelId: workspace.taskThreadsChannelId, - }, - ]; - } - - #primaryWorkspaceSurface(): WorkspaceSurface | undefined { - return this.#workspaceSurfaces()[0]; - } - - #workspaceSurfaceByKey(key: string | undefined): WorkspaceSurface | undefined { - return key - ? this.#workspaceSurfaces().find((surface) => surface.key === key) - : undefined; - } - - #workspaceSurfaceForHomeChannel(channelId: string): WorkspaceSurface | undefined { - return this.#workspaceSurfaces().find((surface) => - surface.homeChannelId === channelId - ); - } - - #workspaceSurfaceForWorkspaceForumChannel(channelId: string): WorkspaceSurface | undefined { - return this.#workspaceSurfaces().find((surface) => - surface.workspaceForumChannelId === channelId - ); - } - - #workspaceSurfaceForTaskThreadsChannel(channelId: string): WorkspaceSurface | undefined { - return this.#workspaceSurfaces().find((surface) => - surface.taskThreadsChannelId === channelId - ); - } - - #workspaceSurfaceForChannel(channelId: string): WorkspaceSurface | undefined { - return this.#workspaceSurfaceForHomeChannel(channelId) ?? - this.#workspaceSurfaceForWorkspaceForumChannel(channelId) ?? - this.#workspaceSurfaceForTaskThreadsChannel(channelId) ?? - this.#workspaceSurfaceForWorkspace(this.#workspaceForChannel(channelId)) ?? - this.#workspaceSurfaceForSession(this.#requireState().sessions.find((session) => - session.discordThreadId === channelId - )); - } - - #workspaceSurfaceForCwd(cwd: string | undefined): WorkspaceSurface | undefined { - const surfaces = this.#workspaceSurfaces(); - if (surfaces.length === 0) { - return undefined; - } - const catchAll = surfaces.find((surface) => - !surface.workspaceCwds || surface.workspaceCwds.length === 0 - ); - if (cwd) { - const workspaceCwd = workspaceCwdForPath(cwd, this.config.cwd); - const exact = surfaces.find((surface) => - (surface.workspaceCwds ?? []).some((surfaceCwd) => - normalizeWorkspaceCwd(surfaceCwd) === workspaceCwd - ) - ); - if (exact) { - return exact; - } - return catchAll; - } - return catchAll ?? surfaces[0]; - } - - #workspaceSurfaceForWorkspace( - workspace: DiscordWorkspaceWorkspaceSurface | undefined, - ): WorkspaceSurface | undefined { - if (!workspace) { - return undefined; - } - return this.#workspaceSurfaceByKey(workspace.surfaceKey) ?? - this.#workspaceSurfaceForCwd(workspace.cwd); - } - - #workspaceSurfaceForDelegation( - delegation: DiscordWorkspaceDelegation, - ): WorkspaceSurface | undefined { - return this.#workspaceSurfaceByKey(delegation.surfaceKey) ?? - this.#workspaceSurfaceForCwd(delegation.cwd); - } - - #workspaceSurfaceForObserved( - observed: DiscordWorkspaceObservedThread, - ): WorkspaceSurface | undefined { - return this.#workspaceSurfaceByKey(observed.surfaceKey) ?? - this.#workspaceSurfaceForCwd(observed.cwd); - } - - #workspaceSurfaceForSession( - session: DiscordBridgeSession | undefined, - ): WorkspaceSurface | undefined { - if (!session) { - return undefined; - } - return this.#workspaceSurfaceByKey(session.surfaceKey) ?? - this.#workspaceSurfaceForHomeChannel(session.discordThreadId) ?? - this.#workspaceSurfaceForTaskThreadsChannel(session.parentChannelId) ?? - this.#workspaceSurfaceForWorkspaceForumChannel(session.parentChannelId) ?? - this.#workspaceSurfaceForCwd(session.cwd); - } - - #workspaceSession(): DiscordBridgeSession | undefined { - const workspaceConfig = this.config.workspace; - if (!workspaceConfig) { - return undefined; - } - const state = this.#requireState(); - const expectedCodexThreadId = workspaceConfig.mainThreadId ?? - state.workspace?.mainThreadId; - return state.sessions.find((session) => - (session.mode === "operator" || session.mode === "workspace") && - session.discordThreadId === workspaceConfig.homeChannelId && - session.parentChannelId === workspaceConfig.homeChannelId && - (!expectedCodexThreadId || session.codexThreadId === expectedCodexThreadId) - ); - } - - #workspaceRunner(): DiscordThreadRunner | undefined { - const session = this.#workspaceSession(); - return session - ? this.#runnersByDiscordThread.get(session.discordThreadId) - : undefined; - } - - #shouldAutoStartRunner(session: DiscordBridgeSession): boolean { - const workbench = this.#workspaceWorkbenchConfig( - this.#workspaceSurfaceForSession(session), - ); - return session.parentChannelId !== workbench?.taskThreadsChannelId; - } - - #isWorkspaceMainThread(threadId: string): boolean { - const session = this.#workspaceSession(); - return Boolean( - (session && session.codexThreadId === threadId) || - this.#requireState().workspace?.mainThreadId === threadId, - ); - } - - #workspaceWorkbenchConfig( - surface: WorkspaceSurface | undefined = this.#primaryWorkspaceSurface(), - ): WorkspaceWorkbenchConfig | undefined { - if (!surface?.workspaceForumChannelId || !surface.taskThreadsChannelId) { - return undefined; - } - return { - surfaceKey: surface.key, - workspaceForumChannelId: surface.workspaceForumChannelId, - taskThreadsChannelId: surface.taskThreadsChannelId, - }; - } - - #workspaceStopHookSpoolDir(): string { - return this.config.hookSpoolDir ?? - path.join(path.dirname(this.config.statePath), "stop-hooks"); - } - - #workspaceDelegations(): DiscordWorkspaceDelegation[] { - const state = this.#requireState(); - if (!state.workspace) { - state.workspace = { - homeChannelId: this.config.workspace?.homeChannelId ?? "", - mainThreadId: this.#workspaceSession()?.codexThreadId, - delegations: [], - workspaces: [], - observedThreads: [], - pendingWakes: [], - processedHookEventIds: [], - processedStopHookEventIds: [], - }; - } - state.workspace.delegations ??= []; - return state.workspace.delegations; - } - - #workspaceWorkspaces(): DiscordWorkspaceWorkspaceSurface[] { - const state = this.#requireState(); - if (!state.workspace) { - state.workspace = { - homeChannelId: this.config.workspace?.homeChannelId ?? "", - mainThreadId: this.#workspaceSession()?.codexThreadId, - delegations: [], - workspaces: [], - observedThreads: [], - pendingWakes: [], - processedHookEventIds: [], - processedStopHookEventIds: [], - }; - } - state.workspace.workspaces ??= []; - return state.workspace.workspaces; - } - - #workspacePendingWakes(): DiscordWorkspacePendingWake[] { - const state = this.#requireState(); - if (!state.workspace) { - state.workspace = { - homeChannelId: this.config.workspace?.homeChannelId ?? "", - mainThreadId: this.#workspaceSession()?.codexThreadId, - delegations: [], - workspaces: [], - observedThreads: [], - pendingWakes: [], - processedHookEventIds: [], - processedStopHookEventIds: [], - }; - } - state.workspace.pendingWakes ??= []; - return state.workspace.pendingWakes; - } - - #workspaceObservedThreads(): DiscordWorkspaceObservedThread[] { - const state = this.#requireState(); - if (!state.workspace) { - state.workspace = { - homeChannelId: this.config.workspace?.homeChannelId ?? "", - mainThreadId: this.#workspaceSession()?.codexThreadId, - delegations: [], - workspaces: [], - observedThreads: [], - pendingWakes: [], - processedHookEventIds: [], - processedStopHookEventIds: [], - }; - } - state.workspace.observedThreads ??= []; - return state.workspace.observedThreads; - } - - #workspaceProcessedHookEventIds(): string[] { - const state = this.#requireState(); - if (!state.workspace) { - state.workspace = { - homeChannelId: this.config.workspace?.homeChannelId ?? "", - mainThreadId: this.#workspaceSession()?.codexThreadId, - delegations: [], - workspaces: [], - observedThreads: [], - pendingWakes: [], - processedHookEventIds: [], - processedStopHookEventIds: [], - }; - } - state.workspace.processedHookEventIds ??= [ - ...(state.workspace.processedStopHookEventIds ?? []), - ]; - return state.workspace.processedHookEventIds; - } - - #workspaceDelegationCapability(): WorkspaceDelegationCapability { - return new WorkspaceDelegationCapability({ - client: this.client, - state: { - delegations: this.#workspaceDelegations() as WorkspaceDelegation[], - pendingWakes: this.#workspacePendingWakes() as WorkspacePendingWake[], - }, - now: () => this.#now(), - threadStartParams: (cwd) => this.#threadStartParams(cwd), - threadResumeParams: (threadId, cwd) => this.#threadResumeParams(threadId, cwd), - turnStartParams: ({ threadId, prompt, cwd }) => ({ - threadId, - input: [{ type: "text", text: prompt, text_elements: [] }], - cwd: cwd ?? null, - model: this.config.model ?? null, - serviceTier: this.config.serviceTier ?? null, - effort: this.config.effort ?? null, - summary: this.config.summary ?? null, - approvalPolicy: this.config.approvalPolicy ?? null, - permissions: this.config.permissions ?? null, - outputSchema: null, - }), - metadataFromArgs: (args) => discordDelegationMetadata(args), - recordResult: async (delegation) => - await this.#recordDelegationResult(delegation as DiscordWorkspaceDelegation), - mirrorResult: async (delegation) => - await this.#mirrorDelegationResult(delegation as DiscordWorkspaceDelegation), - enqueueWake: (input) => this.#enqueueWake(input), - processPendingWakes: async () => await this.#processPendingWakes(), - }); - } - - #applyDiscordDelegationMetadata( - delegation: DiscordWorkspaceDelegation, - args: Record, - ): DiscordWorkspaceDelegation { - delegation.surfaceKey = this.#workspaceSurfaceForCwd( - delegation.cwd ?? this.config.cwd, - )?.key ?? delegation.surfaceKey; - delegation.discordDetailThreadId = stringValue(args.discordDetailThreadId) ?? - discordMetadataString(delegation, "discordDetailThreadId") ?? - delegation.discordDetailThreadId; - delegation.discordTaskThreadId = discordMetadataString( - delegation, - "discordTaskThreadId", - ) ?? delegation.discordTaskThreadId; - delegation.discordWorkspaceThreadId = discordMetadataString( - delegation, - "discordWorkspaceThreadId", - ) ?? delegation.discordWorkspaceThreadId; - delegation.parentDiscordMessageId = stringValue(args.parentDiscordMessageId) ?? - discordMetadataString(delegation, "parentDiscordMessageId") ?? - delegation.parentDiscordMessageId; - return delegation; - } - - async #startDelegation(args: Record): Promise { - const result = await this.#workspaceDelegationCapability().start(args); - const delegation = this.#applyDiscordDelegationMetadata( - result.delegation as DiscordWorkspaceDelegation, - args, - ); - const workbench = await this.#ensureDelegationWorkbench(delegation); - await this.#persist(); - return { delegation, turnId: result.turnId, workbench }; - } - - async #resumeDelegation(args: Record): Promise { - const result = await this.#workspaceDelegationCapability().resume(args); - const delegation = this.#applyDiscordDelegationMetadata( - result.delegation as DiscordWorkspaceDelegation, - args, - ); - const workbench = await this.#ensureDelegationWorkbench(delegation); - await this.#persist(); - return { delegation, workbench }; - } - - async #sendDelegation(args: Record): Promise { - const result = await this.#workspaceDelegationCapability().send(args); - const delegation = result.delegation as DiscordWorkspaceDelegation; - const workbench = await this.#syncDelegationWorkbench(delegation, { - includeTaskResult: false, - }); - await this.#persist(); - return { delegation, turnId: result.turnId, workbench }; - } - - async #readDelegation(args: Record): Promise { - const result = await this.#workspaceDelegationCapability().read(args); - await this.#persist(); - return result; - } - - async #setDelegationPolicy(args: Record): Promise { - const result = this.#workspaceDelegationCapability().setPolicy(args); - await this.#persist(); - return result; - } - - async #flushDelegationResults(args: Record): Promise { - const result = await this.#workspaceDelegationCapability().flushResults(args); - await this.#persist(); - return result; - } - - #delegationGroups(): Array<{ - groupId: string; - total: number; - active: number; - terminal: number; - pendingWake: boolean; - }> { - return this.#workspaceDelegationCapability().listGroups(); - } - - async #ensureDelegationWorkbench( - delegation: DiscordWorkspaceDelegation, - ): Promise { - return await this.#syncDelegationWorkbench(delegation, { - includeTaskResult: false, - }); - } - - async #syncDelegationWorkbench( - delegation: DiscordWorkspaceDelegation, - options: { includeTaskResult: boolean }, - ): Promise { - const surface = this.#workspaceSurfaceForDelegation(delegation); - if (surface) { - delegation.surfaceKey ??= surface.key; - } - const config = this.#workspaceWorkbenchConfig(surface); - if (!config) { - return { enabled: false }; - } - try { - const workspace = await this.#ensureWorkspaceSurface(delegation, config); - if (delegation.discordTaskThreadId) { - await this.#ensureDelegationTaskThread(delegation, workspace, config); - } - if (options.includeTaskResult) { - await this.#mirrorDelegationResultToTaskThread(delegation); - } - await this.#updateWorkspaceSurface(workspace); - return { - enabled: true, - workspace: { - key: workspace.key, - cwd: workspace.cwd, - threadId: workspace.discordThreadId, - }, - taskThreadId: delegation.discordTaskThreadId, - }; - } catch (error) { - const message = errorMessage(error); - this.#debug("workspace.workbench.sync.failed", { - delegationId: delegation.id, - codexThreadId: delegation.codexThreadId, - error: message, - }); - return { enabled: true, error: message }; - } - } - - async #materializeWorkspaceThread( - codexThreadId: string, - input: { author: { id: string }; surface?: WorkspaceSurface }, - ): Promise { - const delegation = this.#delegationForThread(codexThreadId); - const observed = this.#observedThreadForThread(codexThreadId); - let surface = input.surface ?? - (delegation ? this.#workspaceSurfaceForDelegation(delegation) : undefined) ?? - (observed ? this.#workspaceSurfaceForObserved(observed) : undefined); - let config = this.#workspaceWorkbenchConfig(surface); - const existing = this.#requireState().sessions.find((session) => - session.codexThreadId === codexThreadId && - (!config || session.parentChannelId === config.taskThreadsChannelId) - ); - if (existing) { - existing.surfaceKey ??= this.#workspaceSurfaceForSession(existing)?.key; - this.#registerRunner(existing).start(); - return existing; - } - - const resumed = await this.client.resumeThread( - this.#threadResumeParams(codexThreadId, delegation?.cwd ?? observed?.cwd), - ); - const thread = threadFromResponse(resumed); - const cwd = resumeResponseCwd(resumed) ?? thread?.cwd ?? delegation?.cwd ?? - observed?.cwd ?? - this.config.cwd; - surface = surface ?? this.#workspaceSurfaceForCwd(cwd); - if (surface) { - if (delegation) { - delegation.surfaceKey ??= surface.key; - } - if (observed) { - observed.surfaceKey ??= surface.key; - } - } - config = this.#workspaceWorkbenchConfig(surface); - if (!config) { - throw new Error("Workspace workbench is not enabled for this surface."); - } - const existingForSurface = this.#requireState().sessions.find((session) => - session.codexThreadId === codexThreadId && - session.parentChannelId === config.taskThreadsChannelId - ); - if (existingForSurface) { - existingForSurface.surfaceKey ??= surface?.key; - this.#registerRunner(existingForSurface).start(); - return existingForSurface; - } - const title = delegation?.title ?? observed?.title ?? (thread - ? codexThreadTitle(thread) - : `Codex ${compactId(codexThreadId)}`); - const workspace = await this.#ensureWorkspaceSurfaceForCwd( - workspaceCwdForPath(cwd, this.config.cwd), - config, - ); - const discordThreadId = await this.presenter.createThread( - config.taskThreadsChannelId, - truncateDiscordThreadName(`${workspace.title}: ${title}`), - ); - const session: DiscordBridgeSession = { - discordThreadId, - parentChannelId: config.taskThreadsChannelId, - codexThreadId, - title, - createdAt: this.#now().toISOString(), - ownerUserId: input.author.id, - cwd, - mode: "workspace", - surfaceKey: surface?.key, - }; - this.#requireState().sessions.push(session); - this.#registerRunner(session).start(); - - if (delegation) { - delegation.workspaceKey = workspace.key; - delegation.discordWorkspaceThreadId = workspace.discordThreadId; - delegation.discordTaskThreadId = discordThreadId; - delegation.discordDetailThreadId ??= discordThreadId; - delegation.updatedAt = this.#now().toISOString(); - await this.#mirrorDelegationResultToTaskThread(delegation); - } - await this.#updateWorkspaceSurface(workspace); - await this.#persist(); - this.#debug("workspace.workbench.thread.opened", { - codexThreadId, - discordThreadId, - workspaceKey: workspace.key, - }); - return session; - } - - async #reconcileWorkspaceWorkbench(): Promise { - if ( - this.#workspaceSurfaces().every((surface) => - !this.#workspaceWorkbenchConfig(surface) - ) - ) { - return; - } - for (const cwd of await this.#discoverWorkspaceWorkspaceCwds()) { - try { - const surface = this.#workspaceSurfaceForCwd(cwd); - const config = this.#workspaceWorkbenchConfig(surface); - if (!config) { - continue; - } - const workspace = await this.#ensureWorkspaceSurfaceForCwd(cwd, config); - await this.#updateWorkspaceSurface(workspace); - } catch (error) { - this.#error("workspace.workbench.workspaceDiscovery.failed", { - cwd, - error: errorMessage(error), - }); - } - } - for (const delegation of this.#workspaceDelegations()) { - await this.#syncDelegationWorkbench(delegation, { - includeTaskResult: false, - }); - } - await this.#persist(); - } - - async #discoverWorkspaceWorkspaceCwds(): Promise { - const root = normalizeWorkspaceCwd(this.config.cwd); - let entries: Dirent[]; - try { - entries = await readdir(root, { withFileTypes: true }); - } catch (error) { - this.#debug("workspace.workbench.workspaceDiscovery.skipped", { - root, - error: errorMessage(error), - }); - return []; - } - const cwds: string[] = []; - for (const entry of entries) { - if (!isDiscoverableWorkspaceEntry(entry.name)) { - continue; - } - const fullPath = path.join(root, entry.name); - if (entry.isDirectory()) { - cwds.push(fullPath); - continue; - } - if (!entry.isSymbolicLink()) { - continue; - } - try { - if ((await stat(fullPath)).isDirectory()) { - cwds.push(fullPath); - } - } catch { - continue; - } - } - return uniqueStringList(cwds.map((cwd) => normalizeWorkspaceCwd(cwd))).sort( - (left, right) => - workspaceTitle(left).localeCompare(workspaceTitle(right)) || - left.localeCompare(right), - ); - } - - async #ensureWorkspaceSurface( - delegation: DiscordWorkspaceDelegation, - config: WorkspaceWorkbenchConfig, - ): Promise { - const workspace = await this.#ensureWorkspaceSurfaceForCwd( - workspaceCwdForPath(delegation.cwd ?? this.config.cwd, this.config.cwd), - config, - [delegation], - ); - delegation.workspaceKey = workspace.key; - delegation.surfaceKey = workspace.surfaceKey; - delegation.discordWorkspaceThreadId = workspace.discordThreadId; - return workspace; - } - - async #ensureWorkspaceSurfaceForCwd( - cwd: string, - config: WorkspaceWorkbenchConfig, - delegations: DiscordWorkspaceDelegation[] = [], - ): Promise { - if (!this.presenter.createWorkspacePost) { - throw new Error("Workspace presenter cannot create workspace posts."); - } - const normalizedCwd = normalizeWorkspaceCwd(cwd); - const key = workspaceKey(normalizedCwd); - const now = this.#now().toISOString(); - const delegationIds = delegations.map((delegation) => delegation.id); - let workspace = this.#workspaceWorkspaces().find((candidate) => - candidate.key === key && - (candidate.surfaceKey ?? config.surfaceKey) === config.surfaceKey - ); - if (!workspace) { - const title = workspaceTitle(normalizedCwd); - const created = await this.presenter.createWorkspacePost( - config.workspaceForumChannelId, - truncateDiscordThreadName(title), - workspaceDashboardText({ - key, - surfaceKey: config.surfaceKey, - cwd: normalizedCwd, - title, - discordThreadId: "pending", - statusMessageId: undefined, - delegationIds, - createdAt: now, - updatedAt: now, - }, { delegations }), - ); - workspace = { - key, - surfaceKey: config.surfaceKey, - cwd: normalizedCwd, - title, - discordThreadId: created.threadId, - statusMessageId: created.messageId, - delegationIds, - createdAt: now, - updatedAt: now, - }; - this.#workspaceWorkspaces().push(workspace); - this.#debug("workspace.workbench.workspace.created", { - key, - cwd: normalizedCwd, - discordThreadId: workspace.discordThreadId, - }); - if (workspace.statusMessageId) { - await this.#pinMessage(workspace.discordThreadId, workspace.statusMessageId); - } - } - workspace.surfaceKey ??= config.surfaceKey; - workspace.delegationIds = uniqueStringList([ - ...workspace.delegationIds, - ...delegationIds, - ]); - for (const delegation of delegations) { - delegation.surfaceKey ??= config.surfaceKey; - } - workspace.updatedAt = now; - return workspace; - } - - async #ensureDelegationTaskThread( - delegation: DiscordWorkspaceDelegation, - workspace: DiscordWorkspaceWorkspaceSurface, - config: WorkspaceWorkbenchConfig, - ): Promise { - if (!delegation.discordTaskThreadId) { - delegation.discordWorkspaceThreadId = workspace.discordThreadId; - return; - } - const existingSession = delegation.discordTaskThreadId - ? this.#requireState().sessions.find((session) => - session.discordThreadId === delegation.discordTaskThreadId && - session.codexThreadId === delegation.codexThreadId - ) - : undefined; - if (existingSession) { - existingSession.surfaceKey ??= config.surfaceKey; - delegation.discordDetailThreadId ??= delegation.discordTaskThreadId; - delegation.surfaceKey ??= config.surfaceKey; - delegation.discordWorkspaceThreadId = workspace.discordThreadId; - this.#registerRunner(existingSession); - return; - } - if (delegation.discordTaskThreadId) { - const recovered: DiscordBridgeSession = { - discordThreadId: delegation.discordTaskThreadId, - parentChannelId: config.taskThreadsChannelId, - codexThreadId: delegation.codexThreadId, - title: delegation.title, - createdAt: delegation.createdAt, - cwd: delegation.cwd, - mode: "delegated", - surfaceKey: config.surfaceKey, - }; - delegation.discordDetailThreadId ??= delegation.discordTaskThreadId; - delegation.surfaceKey ??= config.surfaceKey; - delegation.discordWorkspaceThreadId = workspace.discordThreadId; - this.#requireState().sessions.push(recovered); - this.#registerRunner(recovered); - return; - } - } - - async #updateWorkspaceSurface( - workspace: DiscordWorkspaceWorkspaceSurface, - ): Promise { - if (!this.presenter.updateMessage) { - return; - } - if (!workspace.statusMessageId) { - return; - } - const delegations = this.#workspaceDelegations().filter((delegation) => - workspace.delegationIds.includes(delegation.id) - ); - const threads = this.#listWorkspaceDashboardThreads(workspace); - await this.presenter.updateMessage( - workspace.discordThreadId, - workspace.statusMessageId, - workspaceDashboardText(workspace, { - delegations, - threads, - }), - ); - if (workspace.statusMessageId) { - await this.#pinMessage(workspace.discordThreadId, workspace.statusMessageId); - } - } - - #listWorkspaceDashboardThreads( - workspace: DiscordWorkspaceWorkspaceSurface, - ): WorkspaceThreadSummary[] { - const byId = new Map(); - const put = (thread: WorkspaceThreadSummary) => { - const existing = byId.get(thread.id); - byId.set(thread.id, { - ...existing, - ...thread, - updatedAt: Math.max(existing?.updatedAt ?? 0, thread.updatedAt), - discordThreadId: existing?.discordThreadId ?? thread.discordThreadId, - }); - }; - - for (const thread of this.#listOpenWorkspaceThreads(workspace)) { - put(thread); - } - for (const delegation of this.#workspaceDelegations()) { - if (this.#workspaceSurfaceForDelegation(delegation)?.key !== - this.#workspaceSurfaceForWorkspace(workspace)?.key) { - continue; - } - const delegationWorkspaceKey = delegation.workspaceKey ?? - workspaceKey(workspaceCwdForPath(delegation.cwd, this.config.cwd)); - if ( - delegationWorkspaceKey !== workspace.key || - (delegation.status !== "active" && delegation.lastStatus !== "in_progress") - ) { - continue; - } - put({ - id: delegation.codexThreadId, - title: delegation.title, - cwd: delegation.cwd ?? workspace.cwd, - status: delegation.lastStatus ?? delegation.status, - updatedAt: Date.parse(delegation.updatedAt) / 1000, - discordThreadId: delegation.discordTaskThreadId, - }); - } - for (const observed of this.#workspaceObservedThreads()) { - if (this.#workspaceSurfaceForObserved(observed)?.key !== - this.#workspaceSurfaceForWorkspace(workspace)?.key) { - continue; - } - const observedWorkspaceKey = observed.workspaceKey ?? - workspaceKey(workspaceCwdForPath(observed.cwd, this.config.cwd)); - if ( - observedWorkspaceKey !== workspace.key || - !isObservedThreadActive(observed) - ) { - continue; - } - put({ - id: observed.threadId, - title: observed.title ?? `Codex ${compactId(observed.threadId)}`, - cwd: observed.cwd ?? workspace.cwd, - status: observedThreadStatusText(observed), - updatedAt: Date.parse(observed.lastSeenAt) / 1000, - discordThreadId: this.#workspaceDiscordThreadForCodexThread( - observed.threadId, - this.#workspaceSurfaceForWorkspace(workspace), - )?.discordThreadId, - }); - } - - return [...byId.values()].sort((left, right) => right.updatedAt - left.updatedAt); - } - - async #listWorkspaceThreads( - workspace: DiscordWorkspaceWorkspaceSurface, - ): Promise { - const byId = new Map(); - const surface = this.#workspaceSurfaceForWorkspace(workspace); - for (const thread of await this.#listCodexThreadSummaries()) { - if ( - workspaceKey(workspaceCwdForPath(thread.cwd, this.config.cwd)) === - workspace.key && - this.#workspaceSurfaceForCwd(thread.cwd)?.key === surface?.key - ) { - byId.set(thread.id, { - ...thread, - discordThreadId: this.#workspaceDiscordThreadForCodexThread( - thread.id, - surface, - )?.discordThreadId, - }); - } - } - for (const delegation of this.#workspaceDelegations()) { - if (this.#workspaceSurfaceForDelegation(delegation)?.key !== surface?.key) { - continue; - } - const delegationWorkspaceKey = delegation.workspaceKey ?? - workspaceKey(workspaceCwdForPath(delegation.cwd, this.config.cwd)); - if ( - delegationWorkspaceKey !== workspace.key || - byId.has(delegation.codexThreadId) - ) { - continue; - } - byId.set(delegation.codexThreadId, { - id: delegation.codexThreadId, - title: delegation.title, - cwd: delegation.cwd ?? workspace.cwd, - status: delegation.lastStatus ?? delegation.status, - updatedAt: Date.parse(delegation.updatedAt) / 1000, - discordThreadId: delegation.discordTaskThreadId, - }); - } - for (const observed of this.#workspaceObservedThreads()) { - if (this.#workspaceSurfaceForObserved(observed)?.key !== surface?.key) { - continue; - } - const observedWorkspaceKey = observed.workspaceKey ?? - workspaceKey(workspaceCwdForPath(observed.cwd, this.config.cwd)); - if (observedWorkspaceKey !== workspace.key) { - continue; - } - const existing = byId.get(observed.threadId); - const observedSummary: WorkspaceThreadSummary = { - id: observed.threadId, - title: observed.title ?? `Codex ${compactId(observed.threadId)}`, - cwd: observed.cwd ?? workspace.cwd, - status: observedThreadStatusText(observed), - updatedAt: Date.parse(observed.lastSeenAt) / 1000, - discordThreadId: this.#workspaceDiscordThreadForCodexThread( - observed.threadId, - surface, - )?.discordThreadId, - }; - byId.set( - observed.threadId, - existing - ? { - ...existing, - status: observedSummary.status, - updatedAt: Math.max(existing.updatedAt, observedSummary.updatedAt), - discordThreadId: existing.discordThreadId ?? - observedSummary.discordThreadId, - } - : observedSummary, - ); - } - return [...byId.values()].sort((left, right) => right.updatedAt - left.updatedAt); - } - - async #listWorkspaceGoalSummaries( - workspace: DiscordWorkspaceWorkspaceSurface, - ): Promise { - const threads = (await this.#listWorkspaceThreads(workspace)).slice( - 0, - threadPickerReactions.length, - ); - return await Promise.all( - threads.map(async (thread) => { - try { - const response = await this.client.getThreadGoal({ - threadId: thread.id, - }); - return { ...thread, goal: response.goal }; - } catch (error) { - return { ...thread, goalError: errorMessage(error) }; - } - }), - ); - } - - async #goalSummaryForSession( - session: DiscordBridgeSession, - ): Promise { - try { - const response = await this.client.getThreadGoal({ - threadId: session.codexThreadId, - }); - return this.#goalSummaryFromSession(session, { goal: response.goal }); - } catch (error) { - return this.#goalSummaryFromSession(session, { - goalError: errorMessage(error), - }); - } - } - - #goalSummaryFromSession( - session: DiscordBridgeSession, - options: Pick = {}, - ): WorkspaceGoalSummary { - return { - id: session.codexThreadId, - title: session.title, - cwd: session.cwd ?? this.config.cwd ?? process.cwd(), - status: this.#isSessionRunning(session, this.#requireState()) - ? "active" - : "open", - updatedAt: Date.parse(session.createdAt) / 1000, - discordThreadId: session.discordThreadId, - ...options, - }; - } - - async #listActiveCodexThreadSummaries( - surface: WorkspaceSurface | undefined = this.#primaryWorkspaceSurface(), - ): Promise { - const byId = new Map(); - const put = (summary: WorkspaceThreadSummary) => { - if (this.#workspaceSurfaceForCwd(summary.cwd)?.key !== surface?.key) { - return; - } - const existing = byId.get(summary.id); - byId.set(summary.id, { - ...existing, - ...summary, - title: summary.title || existing?.title || `Codex ${compactId(summary.id)}`, - cwd: summary.cwd || existing?.cwd || this.config.cwd || process.cwd(), - status: summary.status || existing?.status || "active", - updatedAt: Math.max(existing?.updatedAt ?? 0, summary.updatedAt), - discordThreadId: existing?.discordThreadId ?? - summary.discordThreadId ?? - this.#discordChannelForCodexThread(summary.id, surface), - }); - }; - - for (const thread of await this.#listCodexThreadSummaries()) { - if (thread.status === "active") { - put({ - ...thread, - discordThreadId: this.#discordChannelForCodexThread(thread.id, surface), - }); - } - } - - const state = this.#requireState(); - for (const session of state.sessions) { - if (!this.#isSessionRunning(session, state)) { - continue; - } - put({ - id: session.codexThreadId, - title: session.title, - cwd: session.cwd ?? this.config.cwd ?? process.cwd(), - status: "active", - updatedAt: Date.parse(session.createdAt) / 1000, - discordThreadId: session.discordThreadId, - }); - } - - for (const delegation of this.#workspaceDelegations()) { - if (delegation.status !== "active" && delegation.lastStatus !== "in_progress") { - continue; - } - put({ - id: delegation.codexThreadId, - title: delegation.title, - cwd: delegation.cwd ?? this.config.cwd ?? process.cwd(), - status: delegation.lastStatus ?? delegation.status, - updatedAt: Date.parse(delegation.updatedAt) / 1000, - discordThreadId: this.#discordChannelForCodexThread( - delegation.codexThreadId, - surface, - ), - }); - } - - for (const observed of this.#workspaceObservedThreads()) { - if (!isObservedThreadActive(observed)) { - continue; - } - put({ - id: observed.threadId, - title: observed.title ?? `Codex ${compactId(observed.threadId)}`, - cwd: observed.cwd ?? this.config.cwd ?? process.cwd(), - status: observedThreadStatusText(observed), - updatedAt: Date.parse(observed.lastSeenAt) / 1000, - discordThreadId: this.#discordChannelForCodexThread(observed.threadId, surface), - }); - } - - return [...byId.values()].sort((left, right) => right.updatedAt - left.updatedAt); - } - - #listOpenWorkspaceThreads( - workspace: DiscordWorkspaceWorkspaceSurface, - ): WorkspaceThreadSummary[] { - const surface = this.#workspaceSurfaceForWorkspace(workspace); - const workbench = this.#workspaceWorkbenchConfig(surface); - if (!workbench) { - return []; - } - const sessions = this.#requireState().sessions.filter((session) => - session.parentChannelId === workbench.taskThreadsChannelId && - workspaceKey(workspaceCwdForPath(session.cwd, this.config.cwd)) === - workspace.key && - this.#workspaceSurfaceForSession(session)?.key === surface?.key - ); - return sessions.map((session) => ({ - id: session.codexThreadId, - title: session.title, - cwd: session.cwd ?? workspace.cwd, - status: this.#isSessionRunning(session, this.#requireState()) - ? "active" - : "open", - updatedAt: Date.parse(session.createdAt) / 1000, - discordThreadId: session.discordThreadId, - })).sort((left, right) => right.updatedAt - left.updatedAt); - } - - async #listCodexThreadSummaries(): Promise { - const summaries: WorkspaceThreadSummary[] = []; - let cursor: string | null | undefined; - for (let page = 0; page < 10; page += 1) { - let response: v2.ThreadListResponse; - try { - response = await this.client.listThreads({ - cursor: cursor ?? null, - limit: 100, - sortKey: "updated_at", - sortDirection: "desc", - archived: false, - sourceKinds: [], - useStateDbOnly: false, - }); - } catch (error) { - this.#debug("workspace.workbench.threadList.failed", { - error: errorMessage(error), - }); - return summaries; - } - for (const thread of response.data) { - summaries.push({ - id: thread.id, - title: codexThreadTitle(thread), - cwd: thread.cwd, - status: threadStatusText(thread.status), - updatedAt: thread.updatedAt, - discordThreadId: this.#workspaceDiscordThreadForCodexThread(thread.id) - ?.discordThreadId, - }); - } - if (!response.nextCursor) { - break; - } - cursor = response.nextCursor; - } - return summaries; - } - - #workspaceDiscordThreadForCodexThread( - codexThreadId: string, - surface?: WorkspaceSurface, - ): DiscordBridgeSession | undefined { - const workbench = this.#workspaceWorkbenchConfig(surface); - return this.#requireState().sessions.find((session) => - session.codexThreadId === codexThreadId && - session.parentChannelId === workbench?.taskThreadsChannelId - ); - } - - #discordChannelForCodexThread( - codexThreadId: string, - surface: WorkspaceSurface | undefined = this.#primaryWorkspaceSurface(), - ): string | undefined { - if (this.#isWorkspaceMainThread(codexThreadId)) { - return surface?.homeChannelId ?? this.config.workspace?.homeChannelId; - } - const session = this.#requireState().sessions.find((candidate) => - candidate.codexThreadId === codexThreadId && - this.#workspaceSurfaceForSession(candidate)?.key === surface?.key - ); - const delegation = this.#delegationForThread(codexThreadId); - const delegationChannel = delegation && - this.#workspaceSurfaceForDelegation(delegation)?.key === surface?.key - ? delegation.discordTaskThreadId ?? delegation.discordDetailThreadId - : undefined; - return session?.discordThreadId ?? - delegationChannel; - } - - #workspaceForChannel(channelId: string): DiscordWorkspaceWorkspaceSurface | undefined { - const workspaces = this.#requireState().workspace?.workspaces ?? []; - const direct = workspaces.find((workspace) => - workspace.discordThreadId === channelId - ); - if (direct) { - return direct; - } - const session = this.#requireState().sessions.find((candidate) => - candidate.discordThreadId === channelId - ); - if (!session?.cwd) { - return undefined; - } - const key = workspaceKey(workspaceCwdForPath(session.cwd, this.config.cwd)); - const surface = this.#workspaceSurfaceForSession(session); - return workspaces.find((workspace) => - workspace.key === key && - this.#workspaceSurfaceForWorkspace(workspace)?.key === surface?.key - ); - } - - #sessionForDiscordThread(channelId: string): DiscordBridgeSession | undefined { - const session = this.#requireState().sessions.find((candidate) => - candidate.discordThreadId === channelId - ); - if ( - !session || - session.mode === "operator" || - session.discordThreadId === session.parentChannelId - ) { - return undefined; - } - return session; - } - - #workspaceForGoalSession( - session: DiscordBridgeSession, - ): DiscordWorkspaceWorkspaceSurface { - const existing = this.#workspaceForChannel(session.discordThreadId); - if (existing) { - return existing; - } - const cwd = workspaceCwdForPath(session.cwd, this.config.cwd); - const surface = this.#workspaceSurfaceForSession(session); - return { - key: workspaceKey(cwd), - surfaceKey: surface?.key, - cwd, - title: workspaceTitle(cwd), - discordThreadId: session.parentChannelId, - delegationIds: [], - createdAt: session.createdAt, - updatedAt: session.createdAt, - }; - } - - #workspaceForumForChannel( - channelId: string, - ): DiscordWorkspaceWorkspaceSurface | undefined { - return this.#requireState().workspace?.workspaces?.find((workspace) => - workspace.discordThreadId === channelId - ); - } - - async #mirrorDelegationResultToTaskThread( - delegation: DiscordWorkspaceDelegation, - ): Promise { - if ( - !delegation.discordTaskThreadId || - !delegation.lastFinal || - delegation.taskMirroredAt || - this.#hasDelegationTaskFinalDelivery(delegation) - ) { - return; - } - const outboundMessageIds = await this.presenter.sendMessage( - delegation.discordTaskThreadId, - delegationTaskResultText(delegation), - ); - const deliveredAt = this.#now().toISOString(); - this.#requireState().deliveries.push({ - discordMessageId: `workspace-workbench:${delegation.id}:${delegation.lastTurnId ?? "latest"}`, - discordThreadId: delegation.discordTaskThreadId, - codexThreadId: delegation.codexThreadId, - turnId: delegation.lastTurnId, - kind: "final", - outboundMessageIds, - deliveredAt, - }); - delegation.taskMirroredAt = deliveredAt; - delegation.updatedAt = deliveredAt; - } - - #hasDelegationTaskFinalDelivery(delegation: DiscordWorkspaceDelegation): boolean { - if (!delegation.discordTaskThreadId) { - return false; - } - return this.#requireState().deliveries.some((delivery) => - delivery.kind === "final" && - delivery.discordThreadId === delegation.discordTaskThreadId && - delivery.codexThreadId === delegation.codexThreadId && - (!delegation.lastTurnId || delivery.turnId === delegation.lastTurnId) - ); - } - - async #startWorkspaceStopHookSpool(): Promise { - if (!this.config.workspace || this.#workspaceStopHookWatcher) { - return; - } - const spoolDir = this.#workspaceStopHookSpoolDir(); - await ensureStopHookSpool(spoolDir); - const pendingDir = stopHookSpoolPaths(spoolDir).pending; - this.#workspaceStopHookWatcher = watch(pendingDir, { persistent: false }, () => { - this.#scheduleWorkspaceStopHookDrain(); - }); - this.#workspaceStopHookWatcher.on("error", (error) => { - this.#debug("workspace.stopHook.watch.failed", { - error: errorMessage(error), - }); - }); - await this.#drainWorkspaceStopHookSpool(); - } - - #scheduleWorkspaceStopHookDrain(delayMs = stopHookDrainDebounceMs): void { - if (!this.config.workspace) { - return; - } - if (this.#workspaceStopHookDrainTimer) { - clearTimeout(this.#workspaceStopHookDrainTimer); - } - this.#workspaceStopHookDrainTimer = setTimeout(() => { - this.#workspaceStopHookDrainTimer = undefined; - void this.#drainWorkspaceStopHookSpool().catch((error) => { - this.#debug("workspace.stopHook.drain.failed", { - error: errorMessage(error), - }); - }); - }, delayMs); - this.#workspaceStopHookDrainTimer.unref?.(); - } - - async #drainWorkspaceStopHookSpool(): Promise { - const drain = this.#workspaceStopHookDrainChain - .catch(() => undefined) - .then(() => this.#drainWorkspaceStopHookSpoolOnce()); - this.#workspaceStopHookDrainChain = drain.catch(() => undefined); - await drain; - } - - async #drainWorkspaceStopHookSpoolOnce(): Promise { - if (!this.config.workspace) { - return; - } - const spoolDir = this.#workspaceStopHookSpoolDir(); - const files = await readPendingStopHookSpoolFiles(spoolDir); - let shouldRetry = false; - for (const file of files) { - if ("error" in file) { - this.#debug("workspace.stopHook.file.invalid", { - fileName: file.fileName, - error: file.error.message, - }); - await archiveStopHookSpoolFile(file, spoolDir, "failed"); - continue; - } - const processedIds = this.#workspaceProcessedHookEventIds(); - if (processedIds.includes(file.event.id)) { - await archiveStopHookSpoolFile(file, spoolDir, "ignored"); - continue; - } - const result = await this.#handleWorkspaceHookEvent(file.event); - if (result === "retry") { - shouldRetry = true; - continue; - } - processedIds.push(file.event.id); - if (file.event.eventName === "Stop") { - const workspace = this.#requireState().workspace; - const stopIds = workspace?.processedStopHookEventIds ?? []; - if (!stopIds.includes(file.event.id)) { - stopIds.push(file.event.id); - } - if (workspace) { - workspace.processedStopHookEventIds = stopIds; - } - } - await this.#persist(); - await archiveStopHookSpoolFile( - file, - spoolDir, - result === "processed" ? "processed" : "ignored", - ); - } - if (shouldRetry) { - this.#scheduleWorkspaceStopHookDrain(stopHookRetryMs); - } - } - - async #handleWorkspaceHookEvent( - event: DiscordWorkspaceHookEvent, - ): Promise<"processed" | "ignored" | "retry"> { - const isWorkspaceMain = this.#isWorkspaceMainThread(event.sessionId); - if (!isWorkspaceMain) { - await this.#recordObservedThreadEvent(event); - } - if (event.eventName !== "Stop") { - return "processed"; - } - if (isWorkspaceMain) { - const started = await this.#processPendingWakes({ - completedThreadId: event.sessionId, - completedTurnId: event.turnId, - }); - return started || !this.#workspacePendingWakes().some((wake) => !wake.startedAt) - ? "processed" - : "retry"; - } - const delegation = this.#delegationForThread(event.sessionId); - if (!delegation) { - return "processed"; - } - const completedAt = this.#now().toISOString(); - delegation.status = "complete"; - delegation.lastTurnId = event.turnId ?? delegation.lastTurnId; - delegation.lastStatus = "completed"; - delegation.lastFinal = event.lastAssistantMessage ?? delegation.lastFinal; - delegation.completedAt = completedAt; - delegation.updatedAt = completedAt; - await this.#syncDelegationWorkbench(delegation, { includeTaskResult: true }); - await this.#applyDelegationReturnPolicy(delegation); - await this.#processPendingWakes(); - return "processed"; - } - - async #recordObservedThreadEvent( - event: DiscordWorkspaceHookEvent, - ): Promise { - const observedThreads = this.#workspaceObservedThreads(); - const seenAt = event.createdAt || this.#now().toISOString(); - let observed = observedThreads.find((thread) => - thread.threadId === event.sessionId - ); - if (!observed) { - observed = { - threadId: event.sessionId, - title: observedThreadTitle(event), - status: observedStatusForHookEvent(event), - firstSeenAt: seenAt, - lastSeenAt: seenAt, - updatedAt: seenAt, - }; - observedThreads.push(observed); - } - - const cwd = event.cwd ?? observed.cwd; - const surface = this.#workspaceSurfaceForCwd(cwd); - observed.status = observedStatusForHookEvent(event); - observed.cwd = cwd; - observed.workspaceKey = cwd - ? workspaceKey(workspaceCwdForPath(cwd, this.config.cwd)) - : observed.workspaceKey; - observed.surfaceKey = surface?.key ?? observed.surfaceKey; - observed.model = event.model ?? observed.model; - observed.transcriptPath = event.transcriptPath ?? observed.transcriptPath; - observed.lastTurnId = event.turnId ?? observed.lastTurnId; - observed.lastHookEventName = event.eventName; - observed.source = event.source ?? observed.source; - observed.promptPreview = event.promptPreview ?? observed.promptPreview; - observed.assistantPreview = event.lastAssistantMessage - ? previewText(event.lastAssistantMessage) - : observed.assistantPreview; - observed.toolName = event.toolName ?? observed.toolName; - observed.toolUseId = event.toolUseId ?? observed.toolUseId; - observed.toolInputPreview = event.toolInputPreview ?? observed.toolInputPreview; - observed.toolResponsePreview = event.toolResponsePreview ?? - observed.toolResponsePreview; - observed.permissionDescription = event.permissionDescription ?? - observed.permissionDescription; - observed.title = observedThreadTitle(event, observed); - observed.lastSeenAt = seenAt; - observed.updatedAt = seenAt; - - const config = this.#workspaceWorkbenchConfig(surface); - if (config && cwd) { - const workspace = await this.#ensureWorkspaceSurfaceForCwd( - workspaceCwdForPath(cwd, this.config.cwd), - config, - ); - try { - await this.#updateWorkspaceSurface(workspace); - } catch (error) { - this.#debug("workspace.observed.workspaceUpdate.failed", { - workspaceKey: workspace.key, - threadId: observed.threadId, - error: errorMessage(error), - }); - } - } - } - - async #applyDelegationReturnPolicy( - delegation: DiscordWorkspaceDelegation, - ): Promise { - if (!isTerminalDelegation(delegation)) { - return; - } - const mode = delegation.returnMode ?? "manual"; - if (mode === "detached" || mode === "manual") { - return; - } - await this.#recordDelegationResult(delegation); - await this.#mirrorDelegationResult(delegation); - if (mode === "wake_on_done") { - this.#enqueueWake({ - kind: "delegation", - delegationIds: [delegation.id], - reason: `Delegation ${delegation.title} completed.`, - }); - } - if (mode === "wake_on_group" && delegation.groupId) { - const group = this.#workspaceDelegations().filter((candidate) => - candidate.groupId === delegation.groupId - ); - if (group.length > 0 && group.every(isTerminalDelegation)) { - this.#enqueueWake({ - kind: "group", - groupId: delegation.groupId, - delegationIds: group.map((candidate) => candidate.id), - reason: `Delegation group ${delegation.groupId} completed.`, - }); - } - } - } - - async #recordDelegationResult(delegation: DiscordWorkspaceDelegation): Promise { - const workspaceSession = this.#workspaceSession(); - if (!workspaceSession || delegation.injectedAt) { - return; - } - await this.client.injectThreadItems({ - threadId: workspaceSession.codexThreadId, - items: [ - { - type: "message", - role: "user", - content: [ - { - type: "input_text", - text: delegationResultText(delegation), - }, - ], - }, - ], - }); - delegation.injectedAt = this.#now().toISOString(); - delegation.updatedAt = delegation.injectedAt; - } - - async #mirrorDelegationResult(delegation: DiscordWorkspaceDelegation): Promise { - const surface = this.#workspaceSurfaceForDelegation(delegation); - const homeChannelId = surface?.homeChannelId ?? - (this.config.workspace?.surfaces?.length ? undefined : this.config.workspace?.homeChannelId); - if (!homeChannelId || delegation.mirroredAt) { - return; - } - await this.#syncDelegationWorkbench(delegation, { includeTaskResult: true }); - const hasWorkbenchLinks = Boolean( - delegation.discordWorkspaceThreadId || delegation.discordTaskThreadId, - ); - await this.presenter.sendMessage( - homeChannelId, - this.#workspaceWorkbenchConfig(surface) && hasWorkbenchLinks - ? compactDelegationResultText(delegation) - : delegationResultText(delegation), - ); - delegation.mirroredAt = this.#now().toISOString(); - delegation.updatedAt = delegation.mirroredAt; - } - - #enqueueWake(input: { - kind: DiscordWorkspacePendingWake["kind"]; - delegationIds: string[]; - groupId?: string; - reason: string; - }): void { - const delegationIds = [...new Set(input.delegationIds)].sort(); - if (delegationIds.length === 0) { - return; - } - const wakes = this.#workspacePendingWakes(); - if (wakes.some((wake) => - wake.kind === input.kind && - wake.groupId === input.groupId && - sameStringSet(wake.delegationIds, delegationIds) - )) { - return; - } - wakes.push({ - id: wakeId(input.kind, input.groupId, delegationIds), - kind: input.kind, - groupId: input.groupId, - delegationIds, - reason: input.reason, - createdAt: this.#now().toISOString(), - }); - } - - async #processPendingWakes(options: { - completedThreadId?: string; - completedTurnId?: string; - } = {}): Promise { - const workspaceSession = this.#workspaceSession(); - if ( - !workspaceSession || - this.#isSessionRunning(workspaceSession, this.#requireState(), options) - ) { - return false; - } - const wake = this.#workspacePendingWakes().find((candidate) => !candidate.startedAt); - if (!wake) { - return false; - } - const prompt = wakePrompt(wake, this.#workspaceDelegations()); - let turn: v2.TurnStartResponse; - try { - turn = await this.client.startTurn({ - threadId: workspaceSession.codexThreadId, - input: [{ type: "text", text: prompt, text_elements: [] }], - cwd: workspaceSession.cwd ?? this.config.cwd ?? null, - model: this.config.model ?? null, - serviceTier: this.config.serviceTier ?? null, - effort: this.config.effort ?? null, - summary: this.config.summary ?? null, - approvalPolicy: this.config.approvalPolicy ?? null, - permissions: this.config.permissions ?? null, - outputSchema: null, - }); - } catch (error) { - if (errorMessage(error).includes("already has an active turn")) { - this.#debug("workspace.wake.deferred.activeTurn", { - wakeId: wake.id, - error: errorMessage(error), - }); - return false; - } - throw error; - } - wake.startedAt = this.#now().toISOString(); - for (const delegation of this.#workspaceDelegations()) { - if (wake.delegationIds.includes(delegation.id)) { - delegation.reportedAt = wake.startedAt; - delegation.updatedAt = wake.startedAt; - } - } - this.#debug("workspace.wake.started", { - wakeId: wake.id, - turnId: turn.turn.id, - kind: wake.kind, - groupId: wake.groupId, - }); - return true; - } - - #delegationForThread(threadId: string): DiscordWorkspaceDelegation | undefined { - return this.#workspaceDelegations().find((delegation) => - delegation.codexThreadId === threadId - ); - } - - #observedThreadForThread( - threadId: string, - ): DiscordWorkspaceObservedThread | undefined { - return this.#workspaceObservedThreads().find((thread) => - thread.threadId === threadId - ); - } - - #isSessionRunning( - session: DiscordBridgeSession, - state: DiscordBridgeState, - options: { - completedThreadId?: string; - completedTurnId?: string; - } = {}, - ): boolean { - const isWorkspace = session.mode === "operator"; - const hasActiveTurn = state.activeTurns.some( - (active) => - (isWorkspace || active.discordThreadId === session.discordThreadId) && - active.codexThreadId === session.codexThreadId && - !( - active.codexThreadId === options.completedThreadId && - active.turnId === options.completedTurnId - ), - ); - if (hasActiveTurn) { - return true; - } - return state.queue.some( - (item) => - (isWorkspace || item.discordThreadId === session.discordThreadId) && - item.codexThreadId === session.codexThreadId && - item.status !== "failed" && - !( - item.codexThreadId === options.completedThreadId && - item.turnId === options.completedTurnId - ), - ); - } - - #isAllowedChannel(channelId: string): boolean { - if ( - this.#workspaceSurfaceForHomeChannel(channelId) || - this.#workspaceSurfaceForWorkspaceForumChannel(channelId) || - this.#workspaceSurfaceForTaskThreadsChannel(channelId) - ) { - return true; - } - if (this.config.allowedChannelIds.size === 0) { - return true; - } - if (this.config.allowedChannelIds.has(channelId)) { - return true; - } - if ( - this.#requireState().workspace?.workspaces?.some((workspace) => - workspace.discordThreadId === channelId - ) - ) { - return true; - } - const session = this.#requireState().sessions.find( - (candidate) => candidate.discordThreadId === channelId, - ); - const workbench = this.#workspaceWorkbenchConfig( - this.#workspaceSurfaceForSession(session), - ); - return Boolean( - session && - (this.config.allowedChannelIds.has(session.parentChannelId) || - session.parentChannelId === workbench?.taskThreadsChannelId || - session.parentChannelId === workbench?.workspaceForumChannelId), - ); - } - - #commandRegistrationChannelIds(): string[] { - return uniqueStringList([ - ...this.config.allowedChannelIds, - ...this.#workspaceSurfaces().flatMap((surface) => [ - surface.homeChannelId, - surface.workspaceForumChannelId ?? "", - surface.taskThreadsChannelId ?? "", - ]), - ]); - } - - #isAllowedInboundChannel( - inbound: DiscordMessageInbound | DiscordThreadStartInbound, - ): boolean { - if (!inbound.guildId && this.config.allowedUserIds.has(inbound.author.id)) { - return true; - } - return this.#isAllowedChannel(inbound.channelId); - } - - #isAllowedSessionUser(session: DiscordBridgeSession, userId: string): boolean { - return ( - this.config.allowedUserIds.has(userId) || - session.ownerUserId === userId || - Boolean(session.participantUserIds?.includes(userId)) - ); - } - - #isSessionInClearScope( - session: DiscordBridgeSession, - command: DiscordClearInbound, - ): boolean { - if (!command.guildId) { - return true; - } - return session.guildId === command.guildId || - (!session.guildId && session.parentChannelId === command.channelId); - } - - async #addThreadMembers( - discordThreadId: string, - participantUserIds: string[], - ): Promise { - if (participantUserIds.length === 0 || !this.presenter.addThreadMembers) { - return; - } - try { - await this.presenter.addThreadMembers(discordThreadId, participantUserIds); - this.#debug("discord.thread.members.added", { - discordThreadId, - participantUserIds, - }); - } catch (error) { - this.#debug("discord.thread.members.addFailed", { - discordThreadId, - participantUserIds, - error: errorMessage(error), - }); - } - } - - async #pinMessage(channelId: string, messageId: string): Promise { - if (!this.presenter.pinMessage) { - return; - } - try { - await this.presenter.pinMessage(channelId, messageId); - } catch (error) { - this.#debug("discord.message.pinFailed", { - channelId, - messageId, - error: errorMessage(error), - }); - } - } - - async #deleteSourceMessage(session: DiscordBridgeSession): Promise { - if (!session.sourceMessageId) { - return; - } - try { - await this.presenter.deleteMessage( - session.parentChannelId, - session.sourceMessageId, - ); - this.#debug("clear.sourceMessageDeleted", { - parentChannelId: session.parentChannelId, - sourceMessageId: session.sourceMessageId, - discordThreadId: session.discordThreadId, - }); - } catch (error) { - this.#debug("clear.sourceMessageDeleteFailed", { - parentChannelId: session.parentChannelId, - sourceMessageId: session.sourceMessageId, - discordThreadId: session.discordThreadId, - error: errorMessage(error), - }); - } - } - - #threadStartParams(cwd: string | undefined): v2.ThreadStartParams { - return { - cwd: cwd ?? this.config.cwd ?? null, - model: this.config.model ?? null, - modelProvider: this.config.modelProvider ?? null, - serviceTier: this.config.serviceTier ?? null, - approvalPolicy: this.config.approvalPolicy ?? null, - sandbox: this.config.sandbox ?? null, - permissions: this.config.permissions ?? null, - threadSource: "user", - experimentalRawEvents: false, - persistExtendedHistory: false, - }; - } - - #threadResumeParams( - threadId: string, - cwd: string | undefined, - ): v2.ThreadResumeParams { - return { - threadId, - cwd: cwd ?? null, - model: this.config.model ?? null, - modelProvider: this.config.modelProvider ?? null, - serviceTier: this.config.serviceTier ?? null, - approvalPolicy: this.config.approvalPolicy ?? null, - sandbox: this.config.sandbox ?? null, - permissions: this.config.permissions ?? null, - persistExtendedHistory: false, - }; - } - - async #readThreadSnapshot(threadId: string): Promise { - try { - const response = await this.client.readThread({ - threadId, - includeTurns: true, - }); - return threadSnapshotFromThread(response.thread); - } catch (error) { - this.#debug("thread.final.readFailed", { - threadId, - error: errorMessage(error), - }); - return emptyThreadSnapshot(); - } - } - - #recordResumeHistoryDeliveries( - session: DiscordBridgeSession, - sourceMessageId: string, - snapshot: ThreadSnapshot, - lastFinalOutboundMessageIds: string[], - ): void { - const state = this.#requireState(); - addProcessedMessageId(state, sourceMessageId); - for (const turnId of snapshot.terminalTurnIds) { - if ( - state.deliveries.some((delivery) => - delivery.discordThreadId === session.discordThreadId && - delivery.codexThreadId === session.codexThreadId && - delivery.turnId === turnId && - delivery.kind === "final" - ) - ) { - continue; - } - state.deliveries.push({ - discordMessageId: `resume:${sourceMessageId}:${turnId}`, - discordThreadId: session.discordThreadId, - codexThreadId: session.codexThreadId, - turnId, - kind: "final", - outboundMessageIds: turnId === snapshot.lastFinal?.turnId - ? lastFinalOutboundMessageIds - : [], - deliveredAt: this.#now().toISOString(), - }); - } - } - - async #persist(): Promise { - const save = this.#persistChain - .catch(() => undefined) - .then(async () => { - await this.store.save(this.#requireState()); - this.#debug("state.persisted", { - sessions: this.#requireState().sessions.length, - queue: this.#requireState().queue.length, - deliveries: this.#requireState().deliveries.length, - processed: this.#requireState().processedMessageIds.length, - }); - }); - this.#persistChain = save; - await save; - } - - #requireState(): DiscordBridgeState { - if (!this.#state) { - throw new Error("Discord bridge is not started"); - } - return this.#state; - } - - #debug(event: string, fields: Record = {}): void { - this.#logger.debug(event, fields); - } - - #error(event: string, fields: Record = {}): void { - this.#logger.error(event, fields); - } -} - -export function splitDiscordMessage(text: string): string[] { - const chunks: string[] = []; - let remaining = text.trim(); - while (remaining.length > maxDiscordMessageLength) { - const splitAt = bestSplitIndex(remaining, maxDiscordMessageLength); - chunks.push(remaining.slice(0, splitAt).trimEnd()); - remaining = remaining.slice(splitAt).trimStart(); - } - if (remaining) { - chunks.push(remaining); - } - return chunks.length > 0 ? chunks : [""]; -} - -function threadTitle(command: DiscordThreadStartInbound, prompt = threadPrompt(command)): string { - return truncateDiscordThreadName( - command.title?.trim() || - firstLine(prompt) || - `Codex ${command.author.name}`, - ); -} - -function threadPrompt(command: DiscordThreadStartInbound): string { - let prompt = command.prompt ?? ""; - for (const userId of command.mentionedUserIds ?? []) { - prompt = prompt.replace(new RegExp(`<@!?${escapeRegExp(userId)}>`, "g"), ""); - } - return prompt.trim(); -} - -type ThreadStartIntent = - | { kind: "new"; prompt: string; cwd?: string } - | { kind: "resume"; codexThreadId: string; cwd?: string } - | { kind: "invalid"; message: string }; - -export function parseThreadStartIntent(text: string): ThreadStartIntent { - const tokens = tokenize(text); - const removeRanges: TextRange[] = []; - let cwd: string | undefined; - for (let index = 0; index < tokens.length; index += 1) { - const token = tokens[index]; - if (!token) { - continue; - } - const inlineDir = inlineDirValue(token.value); - if (inlineDir !== undefined) { - cwd = resolveHomeDir(inlineDir); - removeRanges.push({ start: token.start, end: token.end }); - continue; - } - if (token.value === "--dir" || token.value === "--cwd") { - const next = tokens[index + 1]; - if (!next) { - return { kind: "invalid", message: "Missing directory after --dir." }; - } - cwd = resolveHomeDir(next.value); - removeRanges.push({ start: token.start, end: next.end }); - index += 1; - } - } - const remainingText = removeRangesFromText(text, removeRanges).trim(); - const remainingTokens = tokenize(remainingText); - if (remainingTokens[0]?.value === "resume") { - const codexThreadId = remainingTokens[1]?.value; - if (!codexThreadId) { - return { - kind: "invalid", - message: "Usage: @codex resume [--dir path]", - }; - } - return { kind: "resume", codexThreadId, cwd }; - } - return { kind: "new", prompt: remainingText, cwd }; -} - -function resumeThreadTitle( - command: DiscordThreadStartInbound, - codexThreadId: string, -): string { - return truncateDiscordThreadName( - command.title?.trim() || `Codex ${compactId(codexThreadId)}`, - ); -} - -type TextToken = { - value: string; - start: number; - end: number; -}; - -type TextRange = { - start: number; - end: number; -}; - -function tokenize(text: string): TextToken[] { - const tokens: TextToken[] = []; - let index = 0; - while (index < text.length) { - while (index < text.length && /\s/.test(text[index] ?? "")) { - index += 1; - } - if (index >= text.length) { - break; - } - const start = index; - const quote = text[index] === "\"" || text[index] === "'" - ? text[index] - : undefined; - let value = ""; - if (quote) { - index += 1; - while (index < text.length && text[index] !== quote) { - value += text[index] ?? ""; - index += 1; - } - if (text[index] === quote) { - index += 1; - } - tokens.push({ value, start, end: index }); - continue; - } - while (index < text.length && !/\s/.test(text[index] ?? "")) { - value += text[index] ?? ""; - index += 1; - } - tokens.push({ value, start, end: index }); - } - return tokens; -} - -function inlineDirValue(value: string): string | undefined { - if (value.startsWith("--dir=")) { - return value.slice("--dir=".length); - } - if (value.startsWith("--cwd=")) { - return value.slice("--cwd=".length); - } - return undefined; -} - -function removeRangesFromText(text: string, ranges: TextRange[]): string { - if (ranges.length === 0) { - return text; - } - const sorted = [...ranges].sort((left, right) => left.start - right.start); - let result = ""; - let cursor = 0; - for (const range of sorted) { - result += text.slice(cursor, range.start); - cursor = Math.max(cursor, range.end); - } - result += text.slice(cursor); - return result.replace(/[ \t]{2,}/g, " "); -} - -function resolveHomeDir(value: string): string { - 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 truncateDiscordThreadName(name: string): string { - const trimmed = name.trim().replace(/\s+/g, " "); - if (trimmed.length <= 90) { - return trimmed || "Codex thread"; - } - return `${trimmed.slice(0, 87).trimEnd()}...`; -} - -function firstLine(value: string | undefined): string | undefined { - const line = value?.split(/\r?\n/, 1)[0]?.trim(); - return line || undefined; -} - -function bestSplitIndex(text: string, maxLength: number): number { - const newline = text.lastIndexOf("\n", maxLength); - if (newline > maxLength * 0.6) { - return newline; - } - const space = text.lastIndexOf(" ", maxLength); - if (space > maxLength * 0.6) { - return space; - } - return maxLength; -} - -function isDuplicate(state: DiscordBridgeState, messageId: string): boolean { - return ( - state.processedMessageIds.includes(messageId) || - state.queue.some((item) => item.discordMessageId === messageId) || - state.deliveries.some((delivery) => delivery.discordMessageId === messageId) - ); -} - -function workspaceToolSpecs(): v2.DynamicToolSpec[] { - return [ - { - namespace: "codex_workspace", - name: "list_delegations", - description: "List delegated Codex sessions tracked by the Discord workspace.", - inputSchema: objectSchema({}), - }, - { - namespace: "codex_workspace", - name: "start_delegation", - description: "Start a delegated Codex session in a cwd and optionally start its first turn.", - inputSchema: objectSchema({ - cwd: stringSchema("Workspace cwd for the delegated Codex session."), - title: optionalStringSchema("Human title for the delegated work."), - prompt: optionalStringSchema("Optional first prompt to send to the delegated session."), - groupId: optionalStringSchema("Optional delegation group id for fan-out/fan-in orchestration."), - returnMode: optionalStringSchema("Return policy: detached, record_only, wake_on_done, wake_on_group, or manual."), - discordDetailThreadId: optionalStringSchema("Optional Discord detail thread id for noisy work."), - parentDiscordMessageId: optionalStringSchema("Optional Discord message id that requested the delegation."), - }, ["cwd"]), - }, - { - namespace: "codex_workspace", - name: "resume_delegation", - description: "Register an existing Codex thread as delegated work.", - inputSchema: objectSchema({ - threadId: stringSchema("Existing Codex thread id to resume and track."), - cwd: optionalStringSchema("Optional cwd override for the resumed thread."), - title: optionalStringSchema("Human title for the delegated work."), - groupId: optionalStringSchema("Optional delegation group id for fan-out/fan-in orchestration."), - returnMode: optionalStringSchema("Return policy: detached, record_only, wake_on_done, wake_on_group, or manual."), - discordDetailThreadId: optionalStringSchema("Optional Discord detail thread id for noisy work."), - parentDiscordMessageId: optionalStringSchema("Optional Discord message id that requested the delegation."), - }, ["threadId"]), - }, - { - namespace: "codex_workspace", - name: "send_delegation", - description: "Send a prompt as a new turn to a tracked delegated Codex session.", - inputSchema: objectSchema({ - delegationId: optionalStringSchema("Tracked delegation id."), - threadId: optionalStringSchema("Tracked delegated Codex thread id."), - prompt: stringSchema("Prompt to send to the delegated session."), - groupId: optionalStringSchema("Optional delegation group id to assign for this turn."), - returnMode: optionalStringSchema("Return policy: detached, record_only, wake_on_done, wake_on_group, or manual."), - }, ["prompt"]), - }, - { - namespace: "codex_workspace", - name: "read_delegation", - description: "Read and summarize a tracked delegated Codex session.", - inputSchema: objectSchema({ - delegationId: optionalStringSchema("Tracked delegation id."), - threadId: optionalStringSchema("Tracked delegated Codex thread id."), - }), - }, - { - namespace: "codex_workspace", - name: "set_delegation_policy", - description: "Update return policy for one delegation or every delegation in a group.", - inputSchema: objectSchema({ - delegationId: optionalStringSchema("Tracked delegation id."), - threadId: optionalStringSchema("Tracked delegated Codex thread id."), - groupId: optionalStringSchema("Delegation group id."), - returnMode: stringSchema("Return policy: detached, record_only, wake_on_done, wake_on_group, or manual."), - }, ["returnMode"]), - }, - { - namespace: "codex_workspace", - name: "flush_delegation_results", - description: "Manually inject and mirror completed delegation results, optionally waking the main operator.", - inputSchema: objectSchema({ - delegationId: optionalStringSchema("Tracked delegation id."), - threadId: optionalStringSchema("Tracked delegated Codex thread id."), - groupId: optionalStringSchema("Delegation group id."), - wake: optionalStringSchema("Set to false to avoid starting a main operator turn."), - }), - }, - { - namespace: "codex_workspace", - name: "list_delegation_groups", - description: "List delegation groups and their terminal/active counts.", - inputSchema: objectSchema({}), - }, - ]; -} - -function objectSchema( - properties: Record, - required: string[] = [], -): JsonValue { - return { - type: "object", - properties, - required, - additionalProperties: false, - }; -} - -function stringSchema(description: string): JsonValue { - return { type: "string", description }; -} - -function optionalStringSchema(description: string): JsonValue { - return stringSchema(description); -} - -function discordDelegationMetadata( - args: Record, -): Record | undefined { - const metadata = { - kind: "discord", - discordDetailThreadId: stringValue(args.discordDetailThreadId), - discordTaskThreadId: stringValue(args.discordTaskThreadId), - discordWorkspaceThreadId: stringValue(args.discordWorkspaceThreadId), - parentDiscordMessageId: stringValue(args.parentDiscordMessageId), - }; - return Object.values(metadata).some((value) => value !== undefined) - ? metadata - : undefined; -} - -function discordMetadataString( - delegation: DiscordWorkspaceDelegation, - key: string, -): string | undefined { - const metadata = record((delegation as { metadata?: unknown }).metadata); - return metadata.kind === "discord" ? stringValue(metadata[key]) : undefined; -} - -function isTerminalDelegation(delegation: DiscordWorkspaceDelegation): boolean { - return delegation.status === "complete" || - delegation.status === "failed" || - delegation.status === "reported"; -} - -function delegationResultText(delegation: DiscordWorkspaceDelegation): string { - return [ - "[discord-workspace delegation result]", - `Delegation: ${delegation.title}`, - `Delegation ID: ${delegation.id}`, - `Thread: ${delegation.codexThreadId}`, - delegation.groupId ? `Group: ${delegation.groupId}` : undefined, - delegation.cwd ? `Dir: ${delegation.cwd}` : undefined, - `Status: ${delegation.lastStatus ?? delegation.status}`, - delegation.lastTurnId ? `Turn: ${delegation.lastTurnId}` : undefined, - "", - "Result:", - delegation.lastFinal ?? "(no final assistant message captured)", - ].filter((line): line is string => line !== undefined).join("\n"); -} - -function delegationTaskResultText(delegation: DiscordWorkspaceDelegation): string { - return [ - "**Delegation Result**", - `Delegation: ${delegation.title}`, - `Codex thread: \`${delegation.codexThreadId}\``, - delegation.groupId ? `Group: \`${delegation.groupId}\`` : undefined, - `Status: \`${delegation.lastStatus ?? delegation.status}\``, - delegation.lastTurnId ? `Turn: \`${delegation.lastTurnId}\`` : undefined, - "", - delegation.lastFinal ?? "(no final assistant message captured)", - ].filter((line): line is string => line !== undefined).join("\n"); -} - -function compactDelegationResultText(delegation: DiscordWorkspaceDelegation): string { - const links = [ - delegation.discordWorkspaceThreadId - ? `workspace <#${delegation.discordWorkspaceThreadId}>` - : undefined, - delegation.discordTaskThreadId - ? `task <#${delegation.discordTaskThreadId}>` - : undefined, - ].filter((link): link is string => link !== undefined).join(", "); - return [ - "[discord-workspace delegation result]", - `${delegation.title}: ${delegation.lastStatus ?? delegation.status}`, - delegation.groupId ? `Group: ${delegation.groupId}` : undefined, - links ? `Links: ${links}` : undefined, - delegation.lastTurnId ? `Turn: ${delegation.lastTurnId}` : undefined, - ].filter((line): line is string => line !== undefined).join("\n"); -} - -function workspaceDashboardText( - workspace: DiscordWorkspaceWorkspaceSurface, - options: { - delegations?: DiscordWorkspaceDelegation[]; - threads?: WorkspaceThreadSummary[]; - } = {}, -): string { - const delegations = options.delegations ?? []; - const threads = options.threads ?? []; - const visibleThreads = threads.slice(0, 25); - return [ - `**Workspace: ${workspace.title}**`, - `Dir: \`${workspace.cwd}\``, - `Visible threads: ${threads.length}`, - `Tracked delegations: ${delegations.length}`, - "", - "**Visible Threads**", - visibleThreads.length > 0 - ? visibleThreads.map(workspaceThreadLine).join("\n") - : "None", - threads.length > visibleThreads.length - ? `Showing newest ${visibleThreads.length} of ${threads.length} threads.` - : undefined, - "", - "Run `/threads` here to browse or resume workspace Codex threads.", - ].filter((line): line is string => line !== undefined).join("\n"); -} - -function workspaceThreadLine( - thread: WorkspaceThreadSummary, - index: number, -): string { - const link = thread.discordThreadId ? `<#${thread.discordThreadId}>` : "`not opened`"; - const title = truncateDiscordThreadName(thread.title); - return `${index + 1}. ${link} ${title} (${thread.status})`; -} - -function activeThreadStatusLines( - threads: WorkspaceThreadSummary[], - openableThreads: WorkspaceThreadSummary[], -): string[] { - const createIndexById = new Map( - openableThreads.map((thread, index) => [thread.id, index]), - ); - return threads.map((thread) => { - const createIndex = createIndexById.get(thread.id); - const marker = createIndex === undefined - ? "-" - : threadPickerReactions[createIndex] ?? `${createIndex + 1}.`; - const link = thread.discordThreadId ? `<#${thread.discordThreadId}>` : "`not opened`"; - const title = truncateDiscordThreadName(thread.title); - return `${marker} ${link} ${title} (${thread.status})`; - }); -} - -function goalPickerText( - workspace: DiscordWorkspaceWorkspaceSurface, - entries: WorkspaceGoalSummary[], - total: number, -): string { - return [ - `**Goals: ${workspace.title}**`, - `Dir: \`${workspace.cwd}\``, - "", - ...entries.map((entry, index) => { - const link = entry.discordThreadId ? `<#${entry.discordThreadId}>` : "`not opened`"; - const title = truncateDiscordThreadName(entry.title); - return `${threadPickerReactions[index]} ${link} ${title} - ${goalSummaryText(entry)}`; - }), - total > entries.length ? `Showing newest ${entries.length} of ${total}.` : undefined, - "", - "Choose a number to manage that thread's goal.", - ].filter((line): line is string => line !== undefined).join("\n"); -} - -function goalActionText( - workspace: DiscordWorkspaceWorkspaceSurface, - entry: WorkspaceGoalSummary, - options: { prefix?: string } = {}, -): string { - const link = entry.discordThreadId ? `<#${entry.discordThreadId}>` : "`not opened`"; - const goal = entry.goal; - return [ - options.prefix, - `**Goal: ${truncateDiscordThreadName(entry.title)}**`, - `Workspace: ${workspace.title}`, - `Thread: ${link} \`${entry.id}\``, - `Dir: \`${entry.cwd}\``, - "", - entry.goalError - ? `Goal: unavailable (${entry.goalError})` - : goal - ? [ - `Goal: \`${goal.status}\` ${previewText(firstLine(goal.objective) ?? goal.objective, 180)}`, - `Usage: ${goal.tokensUsed} tokens, ${Math.round(goal.timeUsedSeconds)}s${ - goal.tokenBudget ? ` of ${goal.tokenBudget} tokens` : "" - }`, - ].join("\n") - : "Goal: none", - "", - goalActionOptions(entry).length > 0 - ? "Choose an action." - : entry.goal - ? "No goal actions are available for this thread." - : "Use `/goals objective:` in an opened Discord thread to create one.", - ].filter((line): line is string => line !== undefined).join("\n"); -} - -function hasGoalMutation(command: DiscordGoalsInbound): boolean { - return command.objective !== undefined || - command.goalStatus !== undefined || - command.tokenBudget !== undefined; -} - -function goalActionOptions( - entry: WorkspaceGoalSummary, -): Array<{ id: string; label: string }> { - const options: Array<{ id: string; label: string }> = []; - if (!entry.discordThreadId) { - options.push({ id: "open", label: "Open" }); - } - if (entry.goal && !entry.goalError) { - if (entry.goal.status !== "active") { - options.push({ id: "status:active", label: "Active" }); - } - if (entry.goal.status !== "paused") { - options.push({ id: "status:paused", label: "Pause" }); - } - if (entry.goal.status !== "complete") { - options.push({ id: "status:complete", label: "Complete" }); - } - options.push({ id: "clear", label: "Clear" }); - } - return options; -} - -function goalSummaryText(entry: WorkspaceGoalSummary): string { - if (entry.goalError) { - return `goal unavailable (${entry.goalError})`; - } - if (!entry.goal) { - return "no goal"; - } - return `\`${entry.goal.status}\` ${previewText( - firstLine(entry.goal.objective) ?? entry.goal.objective, - 120, - )}`; -} - -function threadPickerText( - workspace: DiscordWorkspaceWorkspaceSurface, - threads: WorkspaceThreadSummary[], - total: number, - options: { action?: string } = {}, -): string { - return [ - `**Threads: ${workspace.title}**`, - `Dir: \`${workspace.cwd}\``, - "", - ...threads.map((thread, index) => { - const link = thread.discordThreadId - ? `<#${thread.discordThreadId}>` - : "`not opened`"; - const title = truncateDiscordThreadName(thread.title); - return `${threadPickerReactions[index]} ${link} ${title} (${thread.status})`; - }), - total > threads.length ? `Showing newest ${threads.length} of ${total}.` : undefined, - "", - options.action ?? "Choose a number to open or resume that thread in Discord.", - ].filter((line): line is string => line !== undefined).join("\n"); -} - -function threadPickerKey(channelId: string, messageId: string): string { - return `${channelId}:${messageId}`; -} - -function threadPickerReactionIndex(emoji: string): number | undefined { - const index = threadPickerReactions.indexOf(emoji); - return index >= 0 ? index : undefined; -} - -async function updateOrReply( - interaction: Pick, - text: string, -): Promise { - if (interaction.update) { - await interaction.update(text); - return; - } - await interaction.reply?.(text); -} - -function threadFromResponse(response: v2.ThreadResumeResponse): v2.Thread | undefined { - const thread = (response as { thread?: unknown }).thread; - return thread && typeof thread === "object" && "id" in thread - ? thread as v2.Thread - : undefined; -} - -function codexThreadTitle(thread: v2.Thread): string { - return thread.name?.trim() || - firstLine(thread.preview)?.trim() || - `Codex ${compactId(thread.id)}`; -} - -function threadStatusText(status: v2.ThreadStatus): string { - return status.type === "active" ? "active" : status.type; -} - -function observedThreadStatusText(thread: DiscordWorkspaceObservedThread): string { - if (thread.status === "waiting" && thread.permissionDescription) { - return `waiting: ${thread.permissionDescription}`; - } - if (thread.status === "tool" && thread.toolName) { - return `tool: ${thread.toolName}`; - } - return thread.status; -} - -function isObservedThreadActive(thread: DiscordWorkspaceObservedThread): boolean { - return thread.status === "starting" || - thread.status === "active" || - thread.status === "tool" || - thread.status === "waiting"; -} - -function observedStatusForHookEvent( - event: DiscordWorkspaceHookEvent, -): DiscordWorkspaceObservedThread["status"] { - if (event.eventName === "SessionStart") { - return "starting"; - } - if (event.eventName === "UserPromptSubmit") { - return "active"; - } - if (event.eventName === "PermissionRequest") { - return "waiting"; - } - if (event.eventName === "PreToolUse" || event.eventName === "PostToolUse") { - return "tool"; - } - return "idle"; -} - -function observedThreadTitle( - event: DiscordWorkspaceHookEvent, - existing?: DiscordWorkspaceObservedThread, -): string { - return firstLine(event.promptPreview)?.trim() || - firstLine(event.lastAssistantMessage)?.trim() || - existing?.title || - `Codex ${compactId(event.sessionId)}`; -} - -function previewText(value: string, maxLength = 500): string { - return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`; -} - -function normalizeWorkspaceCwd(cwd: string | undefined): string { - return path.resolve(cwd ?? process.cwd()); -} - -function workspaceCwdForPath(cwd: string | undefined, root: string | undefined): string { - const normalizedRoot = normalizeWorkspaceCwd(root); - const normalizedCwd = normalizeWorkspaceCwd(cwd ?? normalizedRoot); - const relative = path.relative(normalizedRoot, normalizedCwd); - if (!relative) { - return normalizedRoot; - } - if ( - relative === ".." || - relative.startsWith(`..${path.sep}`) || - path.isAbsolute(relative) - ) { - return normalizedCwd; - } - const [workspaceName] = relative.split(path.sep).filter(Boolean); - return workspaceName ? path.join(normalizedRoot, workspaceName) : normalizedRoot; -} - -function workspaceKey(cwd: string): string { - return `workspace-${createHash("sha256").update(cwd).digest("hex").slice(0, 12)}`; -} - -function workspaceTitle(cwd: string): string { - const base = path.basename(cwd); - return base && base !== path.sep ? base : cwd; -} - -function uniqueStringList(values: string[]): string[] { - return [...new Set(values.filter(Boolean))]; -} - -function isDiscoverableWorkspaceEntry(name: string): boolean { - return Boolean(name) && !name.startsWith(".") && name !== "node_modules"; -} - -function wakePrompt( - wake: DiscordWorkspacePendingWake, - delegations: DiscordWorkspaceDelegation[], -): string { - const matching = delegations.filter((delegation) => - wake.delegationIds.includes(delegation.id) - ); - const summary = matching.map((delegation) => - `- ${delegation.title} (${delegation.id}): ${delegation.lastStatus ?? delegation.status}` - ).join("\n"); - return [ - "[discord-workspace wake]", - wake.reason, - wake.groupId ? `Group: ${wake.groupId}` : undefined, - "", - "Delegation results have already been injected into this thread history.", - "Review them and decide the next step.", - summary ? ["", "Delegations:", summary].join("\n") : undefined, - ].filter((line): line is string => line !== undefined).join("\n"); -} - -function sameStringSet(left: string[], right: string[]): boolean { - if (left.length !== right.length) { - return false; - } - const rightSet = new Set(right); - return left.every((value) => rightSet.has(value)); -} - -function wakeId( - kind: DiscordWorkspacePendingWake["kind"], - groupId: string | undefined, - delegationIds: string[], -): string { - return `wake-${createHash("sha256").update( - JSON.stringify({ kind, groupId, delegationIds }), - ).digest("hex").slice(0, 12)}`; -} - -function record(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function stringValue(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function compactId(value: string): string { - return value.length > 14 ? `${value.slice(0, 6)}...${value.slice(-6)}` : value; -} - -function clearSummary(input: { - deleted: number; - running: number; - failed: number; -}): string { - const parts = [ - `Deleted ${input.deleted} inactive Discord thread${input.deleted === 1 ? "" : "s"}.`, - ]; - if (input.running > 0) { - parts.push(`Left ${input.running} running thread${input.running === 1 ? "" : "s"} alone.`); - } - if (input.failed > 0) { - parts.push(`Failed to delete ${input.failed} thread${input.failed === 1 ? "" : "s"}.`); - } - return parts.join(" "); -} - -function clearWebhooksSummary(input: { deleted: number; failed: number }): string { - const parts = [ - `Deleted ${input.deleted} webhook message${input.deleted === 1 ? "" : "s"}.`, - ]; - if (input.failed > 0) { - parts.push( - `Failed to delete ${input.failed} webhook message${input.failed === 1 ? "" : "s"}.`, - ); - } - return parts.join(" "); -} - -function emptyThreadSnapshot(): ThreadSnapshot { - return { terminalTurnIds: [] }; -} - -function mergeThreadSnapshots( - first: ThreadSnapshot, - second: ThreadSnapshot, -): ThreadSnapshot { - const terminalTurnIds = [ - ...new Set([...first.terminalTurnIds, ...second.terminalTurnIds]), - ]; - return { - terminalTurnIds, - lastFinal: first.lastFinal ?? second.lastFinal, - }; -} - -function threadSnapshotFromThread(thread: { turns?: unknown[] }): ThreadSnapshot { - const turns = Array.isArray(thread.turns) ? thread.turns : []; - const terminalTurnIds: string[] = []; - let lastFinal: ThreadSnapshot["lastFinal"]; - for (const turn of turns) { - const parsed = record(turn); - const turnId = stringValue(parsed.id); - if (turnId && isTerminalTurnStatus(parsed.status)) { - terminalTurnIds.push(turnId); - } - } - for (const turn of [...turns].reverse()) { - const parsed = record(turn); - const turnId = stringValue(parsed.id); - const text = lastFinalTextFromTurn(parsed); - if (turnId && text) { - lastFinal = { turnId, text }; - break; - } - } - if (lastFinal && !terminalTurnIds.includes(lastFinal.turnId)) { - terminalTurnIds.push(lastFinal.turnId); - } - return { - terminalTurnIds: [...new Set(terminalTurnIds)], - lastFinal, - }; -} - -function resumeResponseCwd(response: unknown): string | undefined { - const responseRecord = record(response); - return stringValue(responseRecord.cwd) ?? - stringValue(record(responseRecord.thread).cwd); -} - -function lastFinalTextFromTurn(turn: Record): string { - const items = Array.isArray(turn.items) ? turn.items : []; - for (const item of [...items].reverse()) { - const candidate = record(item); - if ( - candidate.type === "agentMessage" && - candidate.phase === "final_answer" - ) { - return stringValue(candidate.text)?.trim() ?? ""; - } - } - return ""; -} - -function isTerminalTurnStatus(value: unknown): boolean { - return value === "completed" || value === "failed" || value === "interrupted"; -} - -function addProcessedMessageId(state: DiscordBridgeState, messageId: string): void { - state.processedMessageIds = [ - ...state.processedMessageIds.filter((candidate) => candidate !== messageId), - messageId, - ].slice(-1000); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function normalizeParticipantUserIds( - userIds: string[] | undefined, - ownerUserId: string, -): string[] { - return [...new Set((userIds ?? []).filter( - (userId) => userId.length > 0 && userId !== ownerUserId, - ))]; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/apps/discord-bridge/src/logger.ts b/apps/discord-bridge/src/logger.ts deleted file mode 100644 index d113ae8..0000000 --- a/apps/discord-bridge/src/logger.ts +++ /dev/null @@ -1,71 +0,0 @@ -export type DiscordBridgeLogLevel = "debug" | "info" | "warn" | "error"; -export type DiscordBridgeLogLevelSetting = DiscordBridgeLogLevel | "silent"; - -export type DiscordBridgeLogFields = Record; - -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; -}; - -const logLevelRanks: Record = { - 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]; -} diff --git a/apps/discord-bridge/src/pretty-log.ts b/apps/discord-bridge/src/pretty-log.ts deleted file mode 100644 index aa9169a..0000000 --- a/apps/discord-bridge/src/pretty-log.ts +++ /dev/null @@ -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 & { - 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 = { - 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, - output: Pick, -): Promise { - 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; - } -} diff --git a/apps/discord-bridge/src/runner.ts b/apps/discord-bridge/src/runner.ts deleted file mode 100644 index 0eb0a09..0000000 --- a/apps/discord-bridge/src/runner.ts +++ /dev/null @@ -1,2244 +0,0 @@ -import type { JsonRpcNotification } from "@peezy.tech/codex-flows/rpc"; -import type { v2 } from "@peezy.tech/codex-flows/generated"; -import type { CodexWorkspacePresenter } from "./workspace-backend.ts"; - -import type { - DiscordConsoleMessageKind, - DiscordConsoleOutput, -} from "./console-output.ts"; -import type { - CodexBridgeClient, - DiscordBridgeActiveTurn, - DiscordBridgeConfig, - DiscordBridgeDelivery, - DiscordBridgeQueueItem, - DiscordBridgeSession, - DiscordBridgeState, - DiscordMessageInbound, -} from "./types.ts"; - -const maxAttempts = 3; -const defaultTypingIntervalMs = 8_000; -const defaultReconcileIntervalMs = 30_000; -const activeTurnRetryMs = 2_000; -const runningCommandStatusDelayMs = 5_000; - -export type ThreadRunnerContext = { - client: CodexBridgeClient; - presenter: CodexWorkspacePresenter; - config: DiscordBridgeConfig; - getState(): DiscordBridgeState; - persist(): Promise; - now(): Date; - debug(event: string, fields?: Record): void; - consoleOutput?: DiscordConsoleOutput; -}; - -export class DiscordThreadRunner { - readonly session: DiscordBridgeSession; - #context: ThreadRunnerContext; - #mailbox: Promise = Promise.resolve(); - #stopped = false; - #retryTimers = new Map>(); - #typingTimer: ReturnType | undefined; - #typingTurnKey: string | undefined; - #reconcileTimer: ReturnType | undefined; - #runningCommandStatusTimers = new Map>(); - #finalAssistantText = new Map(); - #agentMessageBuffers = new Map(); - #completedAgentMessages = new Set(); - #summaryBuffers = new Map(); - #summaryMessages = new Map(); - #goal: RuntimeGoal | undefined; - #planExplanation: string | undefined; - #planSteps: RuntimePlanStep[] = []; - #planTextBuffers = new Map(); - #runningCommands = new Map(); - #activities = new Map(); - #pinnedStatusMessageId: string | undefined; - - constructor(session: DiscordBridgeSession, context: ThreadRunnerContext) { - this.session = session; - this.#context = context; - } - - start(): void { - void this.#enqueue("runner.start", async () => { - await this.#refreshGoal(); - await this.#ensureStatusMessage(); - await this.#cleanupDeliveredTurnProgress(); - await this.#reconcilePersistedProcessing(); - await this.#reconcilePersistedActiveTurns(); - await this.#processQueue(); - }); - } - - async stop(): Promise { - this.#stopped = true; - for (const timer of this.#retryTimers.values()) { - clearTimeout(timer); - } - this.#retryTimers.clear(); - this.#stopTypingHeartbeat(); - this.#clearReconcileTimer(); - this.#clearRunningCommandStatusTimers(); - await this.#mailbox.catch(() => undefined); - } - - enqueueMessage(message: DiscordMessageInbound): Promise { - return this.#enqueue("runner.enqueueMessage", async () => { - await this.#enqueueMessage(message); - }); - } - - handleNotification(message: JsonRpcNotification): Promise { - return this.#enqueue("runner.notification", async () => { - await this.#handleNotification(message); - }); - } - - flushSummariesForTest(): Promise { - return this.#enqueue("runner.flushSummariesForTest", async () => { - for (const key of [...this.#summaryBuffers.keys()]) { - await this.#finalizeSummary(summaryKeyParts(key)); - } - }); - } - - ensureStatusMessage(): Promise { - return this.#enqueue("runner.ensureStatusMessage", async () => { - await this.#refreshGoal(); - await this.#ensureStatusMessage(); - }); - } - - #enqueue(label: string, work: () => Promise): Promise { - const run = this.#mailbox - .catch(() => undefined) - .then(async () => { - if (this.#stopped) { - return; - } - await work(); - }); - this.#mailbox = run.catch((error) => { - this.#debug(`${label}.error`, { error: errorMessage(error) }); - }); - return run; - } - - async #enqueueMessage(message: DiscordMessageInbound): Promise { - const state = this.#state(); - const content = message.content.trim(); - if (!content) { - this.#debug("message.ignored.empty", { - discordThreadId: this.session.discordThreadId, - messageId: message.messageId, - }); - return; - } - if (isDuplicate(state, message.messageId)) { - this.#debug("message.ignored.duplicate", { - discordThreadId: this.session.discordThreadId, - messageId: message.messageId, - }); - return; - } - const active = this.#activeTurn(); - if (active) { - await this.#steerActiveTurn(active, message, content); - return; - } - const item: DiscordBridgeQueueItem = { - id: `${message.messageId}-${Date.now()}`, - status: "pending", - discordMessageId: message.messageId, - discordThreadId: message.channelId, - codexThreadId: this.session.codexThreadId, - authorId: message.author.id, - authorName: message.author.name, - content, - createdAt: message.createdAt, - receivedAt: this.#context.now().toISOString(), - attempts: 0, - }; - state.queue.push(item); - this.#debug("queue.enqueued", { - queueId: item.id, - discordThreadId: item.discordThreadId, - codexThreadId: item.codexThreadId, - messageId: item.discordMessageId, - contentLength: content.length, - sessionQueueLength: this.#sessionQueueItems().length, - }); - await this.#context.persist(); - await this.#updateStatusMessage(); - await this.#processQueue(); - } - - async #steerActiveTurn( - active: DiscordBridgeActiveTurn, - message: DiscordMessageInbound, - content: string, - ): Promise { - this.#debug("turn.steer.request", { - activeQueueId: active.queueItemId, - origin: active.origin, - turnId: active.turnId, - messageId: message.messageId, - contentLength: content.length, - }); - await this.#context.client.steerTurn({ - threadId: active.codexThreadId, - expectedTurnId: active.turnId, - input: [ - { - type: "text", - text: this.#formatPrompt({ - id: `${message.messageId}-steer`, - status: "pending", - discordMessageId: message.messageId, - discordThreadId: message.channelId, - codexThreadId: this.session.codexThreadId, - authorId: message.author.id, - authorName: message.author.name, - content, - createdAt: message.createdAt, - receivedAt: this.#context.now().toISOString(), - attempts: 0, - }), - text_elements: [], - }, - ], - responsesapiClientMetadata: null, - }); - addProcessedMessageId(this.#state(), message.messageId); - await this.#context.persist(); - this.#debug("turn.steer.accepted", { - activeQueueId: active.queueItemId, - origin: active.origin, - turnId: active.turnId, - messageId: message.messageId, - }); - await this.#updateStatusMessage(); - } - - async #reconcilePersistedProcessing(): Promise { - const processingItems = this.#sessionQueueItems().filter( - (item) => item.status === "processing", - ); - if (processingItems.length === 0) { - return; - } - this.#debug("runner.reconcile.start", { - discordThreadId: this.session.discordThreadId, - codexThreadId: this.session.codexThreadId, - processing: processingItems.length, - }); - for (const item of processingItems) { - if (!item.turnId) { - item.status = "pending"; - item.lastError = "Recovered processing item without a turn id"; - item.nextAttemptAt = undefined; - this.#debug("runner.reconcile.resetMissingTurn", { - queueId: item.id, - }); - continue; - } - const turn = await this.#readTurn(item.turnId); - if (!turn) { - item.status = "pending"; - item.turnId = undefined; - item.lastError = "Recovered processing item whose turn was not found"; - item.nextAttemptAt = new Date( - this.#context.now().getTime() + activeTurnRetryMs, - ).toISOString(); - this.#debug("runner.reconcile.resetMissingRemoteTurn", { - queueId: item.id, - }); - this.#scheduleRetry(item.id, activeTurnRetryMs); - continue; - } - if (turn.status === "completed") { - await this.#completeTurn(this.session.codexThreadId, item.turnId, turn); - continue; - } - if (turn.status === "failed" || turn.status === "interrupted") { - await this.#completeFailedTurn(item, turn.status); - continue; - } - this.#finalAssistantText.set(turnKey(item.codexThreadId, item.turnId), ""); - const active = this.#upsertActiveTurn({ - turnId: item.turnId, - origin: "discord", - queueItemId: item.id, - startedAt: turnStartedAt(turn), - discordThreadId: item.discordThreadId, - }); - await this.#startTypingHeartbeat(active); - this.#scheduleActiveTurnReconcile(active); - await this.#updateStatusMessage(); - } - await this.#context.persist(); - } - - async #reconcilePersistedActiveTurns(): Promise { - const activeTurns = [...this.#sessionActiveTurns()]; - if (activeTurns.length === 0) { - return; - } - this.#debug("runner.reconcileActive.start", { - activeTurns: activeTurns.length, - }); - for (const active of activeTurns) { - const turn = await this.#readTurn(active.turnId); - if (!turn) { - this.#removeActiveTurn(active.turnId); - this.#debug("runner.reconcileActive.removedMissingTurn", { - turnId: active.turnId, - origin: active.origin, - }); - continue; - } - if (turn.status === "completed") { - await this.#completeTurn(active.codexThreadId, active.turnId, turn); - continue; - } - if (turn.status === "failed" || turn.status === "interrupted") { - await this.#completeFailedTurn(active, turn.status); - continue; - } - this.#finalAssistantText.set(turnKey(active.codexThreadId, active.turnId), ""); - await this.#startTypingHeartbeat(active); - this.#scheduleActiveTurnReconcile(active); - await this.#updateStatusMessage(); - } - await this.#context.persist(); - } - - async #processQueue(): Promise { - const active = this.#activeTurn(); - if (active) { - this.#debug("queue.process.activeTurn", { - queueId: active.queueItemId, - origin: active.origin, - turnId: active.turnId, - pending: this.#sessionQueueItems().filter((item) => item.status === "pending").length, - }); - return; - } - for (const item of this.#sessionQueueItems()) { - if (item.status !== "pending") { - continue; - } - const delayMs = retryDelayMs(item, this.#context.now()); - if (delayMs > 0) { - this.#scheduleRetry(item.id, delayMs); - this.#debug("queue.item.delayed", { - queueId: item.id, - delayMs, - attempts: item.attempts, - }); - continue; - } - await this.#startTurn(item); - return; - } - } - - async #startTurn(item: DiscordBridgeQueueItem): Promise { - try { - this.#debug("turn.start.request", { - queueId: item.id, - codexThreadId: item.codexThreadId, - discordThreadId: item.discordThreadId, - inputLength: item.content.length, - cwd: this.#cwd(), - model: this.#context.config.model, - summary: this.#context.config.summary, - progressMode: this.#progressMode(), - }); - const started = await this.#context.client.startTurn({ - threadId: item.codexThreadId, - input: [ - { - type: "text", - text: this.#formatPrompt(item), - text_elements: [], - }, - ], - cwd: this.#cwd() ?? null, - model: this.#context.config.model ?? null, - serviceTier: this.#context.config.serviceTier ?? null, - effort: this.#context.config.effort ?? null, - summary: this.#context.config.summary ?? null, - approvalPolicy: this.#context.config.approvalPolicy ?? null, - permissions: this.#context.config.permissions ?? null, - outputSchema: null, - }); - item.status = "processing"; - item.turnId = started.turn.id; - item.lastError = undefined; - item.nextAttemptAt = undefined; - this.#clearRuntimeState(); - this.#finalAssistantText.set(turnKey(item.codexThreadId, started.turn.id), ""); - const active = this.#upsertActiveTurn({ - turnId: started.turn.id, - origin: "discord", - queueItemId: item.id, - startedAt: turnStartedAt(started.turn), - discordThreadId: item.discordThreadId, - }); - await this.#startTypingHeartbeat(active); - this.#scheduleActiveTurnReconcile(active); - await this.#context.persist(); - await this.#updateStatusMessage(); - this.#debug("turn.start.accepted", { - queueId: item.id, - codexThreadId: item.codexThreadId, - turnId: started.turn.id, - }); - } catch (error) { - const message = errorMessage(error); - if (message.includes("already has an active turn")) { - item.lastError = message; - item.nextAttemptAt = new Date( - this.#context.now().getTime() + activeTurnRetryMs, - ).toISOString(); - await this.#context.persist(); - await this.#updateStatusMessage(); - this.#debug("turn.start.activeTurn", { - queueId: item.id, - codexThreadId: item.codexThreadId, - nextAttemptAt: item.nextAttemptAt, - error: message, - }); - this.#scheduleRetry(item.id, activeTurnRetryMs); - return; - } - item.attempts += 1; - item.lastError = message; - if (item.attempts >= maxAttempts) { - item.status = "failed"; - item.nextAttemptAt = undefined; - await this.#deliverError(item, message); - await this.#context.persist(); - await this.#updateStatusMessage(); - this.#debug("turn.start.failed.permanent", { - queueId: item.id, - attempts: item.attempts, - error: message, - }); - return; - } - item.nextAttemptAt = new Date( - this.#context.now().getTime() + backoffMs(item.attempts), - ).toISOString(); - await this.#context.persist(); - await this.#updateStatusMessage(); - this.#debug("turn.start.failed.retry", { - queueId: item.id, - attempts: item.attempts, - nextAttemptAt: item.nextAttemptAt, - error: message, - }); - this.#scheduleRetry(item.id, retryDelayMs(item, this.#context.now())); - } - } - - async #handleNotification(message: JsonRpcNotification): Promise { - const params = record(message.params); - const threadId = stringValue(params.threadId); - if (threadId !== this.session.codexThreadId) { - this.#debug("notification.ignored.runnerMismatch", { - method: message.method, - threadId, - codexThreadId: this.session.codexThreadId, - }); - return; - } - if (message.method === "thread/goal/updated") { - this.#goal = runtimeGoal(record(params.goal)); - await this.#updateStatusMessage(); - return; - } - if (message.method === "thread/goal/cleared") { - this.#goal = undefined; - await this.#updateStatusMessage(); - return; - } - const turnId = - stringValue(params.turnId) ?? - stringValue(record(params.turn).id); - if (!turnId) { - this.#debug("notification.ignored.runnerMismatch", { - method: message.method, - threadId, - turnId, - codexThreadId: this.session.codexThreadId, - }); - return; - } - this.#debug("notification.received", { - method: message.method, - threadId, - turnId, - itemId: stringValue(params.itemId), - summaryIndex: numberValue(params.summaryIndex), - deltaLength: stringValue(params.delta)?.length, - hasTurnPayload: Boolean(params.turn), - }); - if (this.#hasDelivery(turnId, "final") && !this.#processingItemForTurn(turnId)) { - await this.#ignoreDeliveredTurnNotification(message.method, threadId, turnId); - return; - } - if ( - message.method !== "turn/started" && - message.method !== "turn/completed" && - !this.#activeTurnForTurn(turnId) && - !this.#hasDelivery(turnId, "final") - ) { - await this.#adoptStartedTurn(turnId, record(params.turn)); - } - if (message.method === "turn/started") { - await this.#adoptStartedTurn(turnId, record(params.turn)); - await this.#updateStatusMessage(); - return; - } - if (message.method === "turn/plan/updated") { - this.#planExplanation = stringValue(params.explanation); - this.#planSteps = Array.isArray(params.plan) - ? params.plan.filter(isRecord).map((step) => ({ - step: stringValue(step.step) ?? "", - status: planStepStatus(step.status), - })).filter((step) => step.step) - : []; - await this.#updateStatusMessage(); - return; - } - if (message.method === "item/plan/delta") { - const itemId = stringValue(params.itemId) ?? "plan"; - const delta = stringValue(params.delta); - if (delta) { - this.#planTextBuffers.set( - itemId, - `${this.#planTextBuffers.get(itemId) ?? ""}${delta}`, - ); - await this.#updateStatusMessage(); - } - return; - } - if (message.method === "item/started") { - await this.#handleItemStarted(turnId, record(params.item)); - return; - } - if (message.method === "item/commandExecution/outputDelta") { - const itemId = stringValue(params.itemId) ?? "command"; - this.#upsertRunningCommand(itemId, undefined); - await this.#updateStatusMessage(); - return; - } - if (message.method === "item/reasoning/summaryPartAdded") { - if (this.#progressMode() !== "summary") { - return; - } - const summaryKey = summaryNotificationKey(threadId, turnId, params); - await this.#finalizeEarlierSummaries(summaryKey); - if (this.#summaryBuffers.get(summaryKeyString(summaryKey))?.trim()) { - await this.#finalizeSummary(summaryKey); - } else { - this.#ensureSummary(summaryKey); - } - return; - } - if (message.method === "item/reasoning/summaryTextDelta") { - if (this.#progressMode() !== "summary") { - return; - } - const delta = stringValue(params.delta); - if (delta) { - await this.#appendSummary(summaryNotificationKey(threadId, turnId, params), delta); - } - return; - } - if (message.method === "item/completed") { - await this.#handleItemCompleted(threadId, turnId, record(params.item)); - return; - } - if (message.method === "item/agentMessage/delta") { - const delta = stringValue(params.delta); - if (delta) { - this.#appendAgentMessageDelta( - threadId, - turnId, - stringValue(params.itemId) ?? "agent-message", - delta, - ); - } - return; - } - if (message.method === "turn/completed") { - await this.#completeTurn(threadId, turnId, record(params.turn)); - } - } - - async #adoptStartedTurn( - turnId: string, - turn: Record, - ): Promise { - const existing = this.#activeTurnForTurn(turnId); - const previous = this.#activeTurn(); - const item = this.#processingItemForTurn(turnId); - if (!existing && previous?.turnId !== turnId) { - this.#clearRuntimeState(); - } - const active = this.#upsertActiveTurn({ - turnId, - origin: item ? "discord" : "external", - queueItemId: item?.id, - startedAt: turnStartedAt(turn), - discordThreadId: item?.discordThreadId, - }); - if (!this.#finalAssistantText.has(turnKey(active.codexThreadId, active.turnId))) { - this.#finalAssistantText.set(turnKey(active.codexThreadId, active.turnId), ""); - } - await this.#startTypingHeartbeat(active); - this.#scheduleActiveTurnReconcile(active); - await this.#context.persist(); - this.#debug("turn.adopted", { - turnId, - origin: active.origin, - queueId: active.queueItemId, - }); - } - - async #handleItemCompleted( - threadId: string, - turnId: string, - item: Record, - ): Promise { - const trackedActivity = this.#handleActivityItem(turnId, item, "completed"); - if (item.type === "commandExecution") { - await this.#handleCommandExecutionItem(item); - return; - } - if (item.type === "plan") { - const itemId = stringValue(item.id) ?? "plan"; - const text = stringValue(item.text); - if (text) { - this.#planTextBuffers.set(itemId, text); - await this.#updateStatusMessage(); - } - return; - } - if (item.type === "agentMessage") { - await this.#handleAgentMessageCompleted(threadId, turnId, item); - return; - } - if (trackedActivity) { - await this.#updateStatusMessage(); - return; - } - if (this.#progressMode() !== "summary") { - return; - } - if (item.type !== "reasoning" || !Array.isArray(item.summary)) { - return; - } - for (let index = 0; index < item.summary.length; index += 1) { - const text = stringValue(item.summary[index]); - if (!text) { - continue; - } - const summaryParts = { - threadId, - turnId, - itemId: stringValue(item.id) ?? "reasoning", - summaryIndex: index, - }; - const encodedKey = summaryKeyString(summaryParts); - if (!this.#summaryMessages.has(encodedKey)) { - this.#summaryBuffers.set(encodedKey, text); - await this.#finalizeSummary(summaryParts); - } - } - } - - async #handleItemStarted( - turnId: string, - item: Record, - ): Promise { - const trackedActivity = this.#handleActivityItem(turnId, item, "inProgress"); - if (item.type === "commandExecution") { - await this.#handleCommandExecutionItem(item); - return; - } - if (item.type === "plan") { - const itemId = stringValue(item.id) ?? `plan-${turnId}`; - this.#planTextBuffers.set(itemId, stringValue(item.text) ?? ""); - await this.#updateStatusMessage(); - } - if (trackedActivity) { - await this.#updateStatusMessage(); - } - } - - async #handleCommandExecutionItem( - item: Record, - ): Promise { - const itemId = stringValue(item.id) ?? "command"; - const status = commandStatus(item.status); - if (status === "inProgress") { - this.#upsertRunningCommand(itemId, stringValue(item.command)); - } else { - this.#deleteRunningCommand(itemId); - } - await this.#updateStatusMessage(); - } - - #handleActivityItem( - turnId: string, - item: Record, - fallbackStatus: RuntimeActivity["status"], - ): boolean { - const activity = activityFromItem( - item, - turnId, - fallbackStatus, - this.#context.now(), - ); - if (!activity) { - return false; - } - this.#activities.set(activity.itemId, activity); - this.#trimActivities(); - return true; - } - - #trimActivities(): void { - const activities = [...this.#activities.values()] - .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)); - const completed = activities.filter((activity) => - activity.status !== "inProgress" - ); - while (this.#activities.size > 8 && completed.length > 0) { - const oldest = completed.shift(); - if (!oldest) { - break; - } - this.#activities.delete(oldest.itemId); - } - while (this.#activities.size > 12) { - const oldest = [...this.#activities.values()] - .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))[0]; - if (!oldest) { - break; - } - this.#activities.delete(oldest.itemId); - } - } - - #upsertRunningCommand(itemId: string, command: string | undefined): void { - const existing = this.#runningCommands.get(itemId); - const running: RunningCommand = { - itemId, - command: command ?? existing?.command ?? `command ${compactId(itemId)}`, - status: "inProgress", - startedAt: existing?.startedAt ?? this.#context.now().toISOString(), - lastOutputAt: this.#context.now().toISOString(), - }; - this.#runningCommands.set(itemId, running); - this.#scheduleRunningCommandStatusRefresh(running); - } - - #deleteRunningCommand(itemId: string): void { - this.#runningCommands.delete(itemId); - this.#clearRunningCommandStatusTimer(itemId); - } - - #scheduleRunningCommandStatusRefresh(command: RunningCommand): void { - if (this.#visibleRunningCommand(command)) { - this.#clearRunningCommandStatusTimer(command.itemId); - return; - } - if (this.#runningCommandStatusTimers.has(command.itemId)) { - return; - } - const startedAtMs = Date.parse(command.startedAt); - const elapsedMs = Number.isFinite(startedAtMs) - ? this.#context.now().getTime() - startedAtMs - : 0; - const delayMs = Math.max(0, runningCommandStatusDelayMs - elapsedMs); - const timer = setTimeout(() => { - this.#runningCommandStatusTimers.delete(command.itemId); - void this.#enqueue("command.status.visible", async () => { - const current = this.#runningCommands.get(command.itemId); - if (current && this.#visibleRunningCommand(current)) { - await this.#updateStatusMessage(); - } - }); - }, delayMs); - timer.unref?.(); - this.#runningCommandStatusTimers.set(command.itemId, timer); - } - - #clearRunningCommandStatusTimer(itemId: string): void { - const timer = this.#runningCommandStatusTimers.get(itemId); - if (!timer) { - return; - } - clearTimeout(timer); - this.#runningCommandStatusTimers.delete(itemId); - } - - #clearRunningCommandStatusTimers(): void { - for (const timer of this.#runningCommandStatusTimers.values()) { - clearTimeout(timer); - } - this.#runningCommandStatusTimers.clear(); - } - - #appendAgentMessageDelta( - threadId: string, - turnId: string, - itemId: string, - delta: string, - ): void { - const encodedKey = agentMessageKey({ threadId, turnId, itemId }); - this.#agentMessageBuffers.set( - encodedKey, - `${this.#agentMessageBuffers.get(encodedKey) ?? ""}${delta}`, - ); - this.#debug("agentMessage.delta.buffered", { - itemId, - turnId, - deltaLength: delta.length, - bufferLength: this.#agentMessageBuffers.get(encodedKey)?.length, - }); - } - - async #handleAgentMessageCompleted( - threadId: string, - turnId: string, - item: Record, - ): Promise { - const itemId = stringValue(item.id) ?? "agent-message"; - const encodedKey = agentMessageKey({ threadId, turnId, itemId }); - if (this.#completedAgentMessages.has(encodedKey)) { - return; - } - const text = - stringValue(item.text)?.trim() ?? - this.#agentMessageBuffers.get(encodedKey)?.trim() ?? - ""; - const phase = messagePhase(item.phase); - this.#completedAgentMessages.add(encodedKey); - this.#agentMessageBuffers.delete(encodedKey); - if (!text) { - return; - } - if (phase === "commentary") { - if (this.#progressMode() === "commentary") { - await this.#sendCommentaryMessage(turnId, itemId, text); - } - return; - } - if (phase === "final_answer" || !phase) { - const key = turnKey(threadId, turnId); - const existing = this.#finalAssistantText.get(key)?.trim(); - this.#finalAssistantText.set( - key, - existing ? `${existing}\n\n${text}` : text, - ); - } - } - - async #sendCommentaryMessage( - turnId: string, - itemId: string, - text: string, - ): Promise { - const active = this.#activeTurnForTurn(turnId) ?? - this.#upsertActiveTurn({ turnId, origin: "external" }); - const outboundMessageIds = await this.#context.presenter.sendMessage( - active.discordThreadId, - text, - ); - this.#recordDeliveryForTurn(active, "commentary", outboundMessageIds); - this.#emitConsoleMessage("commentary", turnId, text); - await this.#context.persist(); - this.#debug("commentary.message.sent", { - turnId, - itemId, - outboundMessageIds, - textLength: text.length, - }); - } - - #ensureSummary(key: SummaryKeyParts): void { - const encodedKey = summaryKeyString(key); - if (!this.#summaryBuffers.has(encodedKey)) { - this.#summaryBuffers.set(encodedKey, ""); - this.#debug("summary.ensure", { - itemId: key.itemId, - summaryIndex: key.summaryIndex, - turnId: key.turnId, - }); - } - } - - async #appendSummary(key: SummaryKeyParts, delta: string): Promise { - const encodedKey = summaryKeyString(key); - this.#summaryBuffers.set( - encodedKey, - `${this.#summaryBuffers.get(encodedKey) ?? ""}${delta}`, - ); - this.#debug("summary.delta.buffered", { - itemId: key.itemId, - summaryIndex: key.summaryIndex, - turnId: key.turnId, - deltaLength: delta.length, - bufferLength: this.#summaryBuffers.get(encodedKey)?.length, - }); - } - - async #sendSummaryMessage(key: SummaryKeyParts): Promise { - const encodedKey = summaryKeyString(key); - const text = this.#summaryBuffers.get(encodedKey)?.trim(); - if (!text) { - return; - } - const active = this.#activeTurnForTurn(key.turnId) ?? - this.#upsertActiveTurn({ turnId: key.turnId, origin: "external" }); - if (this.#summaryMessages.has(encodedKey)) { - this.#debug("summary.send.skipped.alreadySent", { - turnId: key.turnId, - itemId: key.itemId, - summaryIndex: key.summaryIndex, - textLength: text.length, - }); - return; - } - const outboundMessageIds = await this.#context.presenter.sendMessage( - active.discordThreadId, - text, - ); - this.#summaryMessages.set(encodedKey, outboundMessageIds); - this.#recordDeliveryForTurn(active, "summary", outboundMessageIds); - this.#emitConsoleMessage("summary", key.turnId, text); - await this.#context.persist(); - this.#debug("summary.message.sent", { - turnId: key.turnId, - itemId: key.itemId, - summaryIndex: key.summaryIndex, - outboundMessageIds, - textLength: text.length, - }); - } - - async #finalizeSummary(key: SummaryKeyParts): Promise { - const encodedKey = summaryKeyString(key); - this.#debug("summary.finalize", { - turnId: key.turnId, - itemId: key.itemId, - summaryIndex: key.summaryIndex, - bufferLength: this.#summaryBuffers.get(encodedKey)?.length, - }); - await this.#sendSummaryMessage(key); - this.#summaryBuffers.delete(encodedKey); - } - - async #finalizeEarlierSummaries(key: SummaryKeyParts): Promise { - for (const encodedKey of [...this.#summaryBuffers.keys()]) { - const parts = summaryKeyParts(encodedKey); - if ( - parts.threadId === key.threadId && - parts.turnId === key.turnId && - parts.itemId === key.itemId && - parts.summaryIndex < key.summaryIndex - ) { - await this.#finalizeSummary(parts); - } - } - } - - async #ignoreDeliveredTurnNotification( - method: string, - threadId: string, - turnId: string, - ): Promise { - const active = this.#activeTurnForTurn(turnId); - const cleaned = await this.#deleteProgressMessagesForTurn( - active ?? this.#progressCleanupTarget(turnId), - turnId, - ); - if (active) { - this.#removeActiveTurn(turnId); - this.#clearAgentMessagesForTurn(turnId); - this.#clearSummariesForTurn(turnId); - this.#clearRuntimeState(); - this.#stopTypingHeartbeat(); - this.#clearReconcileTimer(); - } - if (active || cleaned) { - await this.#context.persist(); - await this.#updateStatusMessage(); - } - this.#debug("notification.ignored.deliveredTurn", { - method, - threadId, - turnId, - hadActiveTurn: Boolean(active), - cleanedProgress: cleaned, - }); - } - - async #cleanupDeliveredTurnProgress(): Promise { - const deliveredTurnIds = [ - ...new Set( - this.#state().deliveries - .filter( - (delivery) => - (this.session.mode === "operator" || - delivery.discordThreadId === this.session.discordThreadId) && - delivery.codexThreadId === this.session.codexThreadId && - delivery.kind === "final" && - Boolean(delivery.turnId), - ) - .map((delivery) => delivery.turnId as string), - ), - ]; - let changed = false; - for (const turnId of deliveredTurnIds) { - if (this.#processingItemForTurn(turnId)) { - continue; - } - const active = this.#activeTurnForTurn(turnId); - const cleaned = await this.#deleteProgressMessagesForTurn( - active ?? this.#progressCleanupTarget(turnId), - turnId, - ); - if (active) { - this.#removeActiveTurn(turnId); - changed = true; - } - changed = cleaned || changed; - } - if (changed) { - this.#clearRuntimeState(); - this.#stopTypingHeartbeat(); - this.#clearReconcileTimer(); - await this.#context.persist(); - await this.#updateStatusMessage(); - } - } - - async #completeTurn( - threadId: string, - turnId: string, - completedTurn: Record, - ): Promise { - const key = turnKey(threadId, turnId); - const item = this.#processingItemForTurn(turnId); - const active = this.#activeTurnForTurn(turnId) ?? - (item - ? this.#upsertActiveTurn({ - turnId, - origin: "discord", - queueItemId: item.id, - startedAt: turnStartedAt(completedTurn), - discordThreadId: item.discordThreadId, - }) - : this.#upsertActiveTurn({ - turnId, - origin: "external", - startedAt: turnStartedAt(completedTurn), - })); - try { - this.#debug("turn.complete.start", { - queueId: item?.id, - origin: active.origin, - threadId, - turnId, - finalTextLength: this.#finalAssistantText.get(key)?.length ?? 0, - summaryBuffers: this.#summaryBuffers.size, - }); - await this.#handleCompletedTurnItems(threadId, turnId, completedTurn); - await this.#flushSummariesForTurn(turnId); - const finalText = - (this.#finalAssistantText.get(key) ?? "").trim() || - finalTextFromTurn(completedTurn).trim() || - (await this.#readFinalTurnText(turnId)).trim(); - if (finalText && !this.#hasDelivery(turnId, "final")) { - const outboundMessageIds = await this.#context.presenter.sendMessage( - active.discordThreadId, - finalText, - ); - this.#recordDeliveryForTurn(active, "final", outboundMessageIds); - this.#emitConsoleMessage("final", turnId, finalText); - this.#debug("turn.final.sent", { - queueId: item?.id, - origin: active.origin, - turnId, - outboundMessageIds, - textLength: finalText.length, - }); - await this.#deleteProgressMessagesForTurn(active, turnId); - } else { - this.#debug("turn.final.empty", { - queueId: item?.id, - origin: active.origin, - turnId, - alreadyDelivered: this.#hasDelivery(turnId, "final"), - }); - } - if (item) { - this.#removeQueueItem(item); - addProcessedMessageId(this.#state(), item.discordMessageId); - } - this.#removeActiveTurn(turnId); - await this.#context.persist(); - } catch (error) { - if (item) { - item.status = "failed"; - item.lastError = errorMessage(error); - item.nextAttemptAt = undefined; - } - this.#removeActiveTurn(turnId); - await this.#context.persist(); - this.#debug("turn.complete.failedDelivery", { - queueId: item?.id, - origin: active.origin, - turnId, - error: errorMessage(error), - }); - } finally { - this.#finalAssistantText.delete(key); - this.#clearAgentMessagesForTurn(turnId); - this.#clearSummariesForTurn(turnId); - this.#clearRuntimeState(); - this.#stopTypingHeartbeat(); - this.#clearReconcileTimer(); - await this.#updateStatusMessage(); - } - await this.#processQueue(); - } - - async #completeFailedTurn( - activeOrItem: DiscordBridgeActiveTurn | DiscordBridgeQueueItem, - status: "failed" | "interrupted", - ): Promise { - const active = isActiveTurn(activeOrItem) - ? activeOrItem - : this.#upsertActiveTurn({ - turnId: activeOrItem.turnId ?? "unknown", - origin: "discord", - queueItemId: activeOrItem.id, - discordThreadId: activeOrItem.discordThreadId, - }); - const item = this.#processingItemForTurn(active.turnId); - await this.#deliverError(active, `Codex turn ${status}.`); - if (item) { - this.#removeQueueItem(item); - addProcessedMessageId(this.#state(), item.discordMessageId); - } - this.#removeActiveTurn(active.turnId); - this.#stopTypingHeartbeat(); - this.#clearReconcileTimer(); - await this.#context.persist(); - this.#clearRuntimeState(); - await this.#updateStatusMessage(); - this.#debug("turn.reconcile.completedFailed", { - queueId: item?.id, - origin: active.origin, - turnId: active.turnId, - status, - }); - } - - async #flushSummariesForTurn(turnId: string): Promise { - for (const key of [...this.#summaryBuffers.keys()]) { - const parts = summaryKeyParts(key); - if (parts.turnId === turnId) { - await this.#finalizeSummary(parts); - } - } - } - - async #handleCompletedTurnItems( - threadId: string, - turnId: string, - completedTurn: Record, - ): Promise { - const items = Array.isArray(completedTurn.items) ? completedTurn.items : []; - for (const item of items.filter(isRecord)) { - await this.#handleItemCompleted(threadId, turnId, item); - } - } - - #clearAgentMessagesForTurn(turnId: string): void { - for (const key of [...this.#agentMessageBuffers.keys()]) { - if (agentMessageKeyParts(key).turnId === turnId) { - this.#agentMessageBuffers.delete(key); - } - } - for (const key of [...this.#completedAgentMessages]) { - if (agentMessageKeyParts(key).turnId === turnId) { - this.#completedAgentMessages.delete(key); - } - } - } - - #clearSummariesForTurn(turnId: string): void { - for (const key of [...this.#summaryBuffers.keys()]) { - if (summaryKeyParts(key).turnId === turnId) { - this.#summaryBuffers.delete(key); - } - } - for (const key of [...this.#summaryMessages.keys()]) { - if (summaryKeyParts(key).turnId === turnId) { - this.#summaryMessages.delete(key); - } - } - } - - async #readTurn(turnId: string): Promise { - try { - const response = await this.#context.client.readThread({ - threadId: this.session.codexThreadId, - includeTurns: true, - }); - return response.thread.turns.find((candidate) => candidate.id === turnId); - } catch (error) { - this.#debug("turn.read.error", { - turnId, - error: errorMessage(error), - }); - return undefined; - } - } - - async #readFinalTurnText(turnId: string): Promise { - const turn = await this.#readTurn(turnId); - if (!turn) { - return ""; - } - return finalTextFromTurn(turn); - } - - async #deliverError( - target: DiscordBridgeActiveTurn | DiscordBridgeQueueItem, - message: string, - ): Promise { - const active = isActiveTurn(target) ? target : undefined; - const item = active ? undefined : target as DiscordBridgeQueueItem; - const outboundMessageIds = await this.#context.presenter.sendMessage( - target.discordThreadId, - `Codex turn failed: ${message}`, - ); - this.#emitConsoleMessage("error", target.turnId, `Codex turn failed: ${message}`); - if (active) { - this.#recordDeliveryForTurn(active, "error", outboundMessageIds); - } else if (item) { - this.#state().deliveries.push({ - discordMessageId: item.discordMessageId, - discordThreadId: item.discordThreadId, - codexThreadId: item.codexThreadId, - turnId: item.turnId, - kind: "error", - outboundMessageIds, - deliveredAt: this.#context.now().toISOString(), - }); - } - this.#debug("error.delivered", { - queueId: active ? active.queueItemId : item?.id, - origin: active?.origin ?? "discord", - turnId: target.turnId, - outboundMessageIds, - errorLength: message.length, - }); - } - - async #deleteProgressMessagesForTurn( - active: DiscordBridgeActiveTurn, - turnId: string, - ): Promise { - const progressDeliveries = this.#state().deliveries.filter( - (delivery) => - (delivery.kind === "summary" || - delivery.kind === "commentary") && - delivery.turnId === turnId && - delivery.discordThreadId === active.discordThreadId && - delivery.codexThreadId === active.codexThreadId, - ); - const messageIds = [ - ...new Set( - progressDeliveries.flatMap((delivery) => delivery.outboundMessageIds), - ), - ]; - if (messageIds.length === 0) { - this.#debug("progress.cleanup.skipped.empty", { - queueId: active.queueItemId, - origin: active.origin, - turnId, - }); - return false; - } - this.#debug("progress.cleanup.start", { - queueId: active.queueItemId, - origin: active.origin, - turnId, - messageIds, - }); - const deletedMessageIds = new Set(); - for (const messageId of messageIds) { - try { - await this.#context.presenter.deleteMessage( - active.discordThreadId, - messageId, - ); - deletedMessageIds.add(messageId); - this.#debug("progress.cleanup.deleted", { - queueId: active.queueItemId, - origin: active.origin, - turnId, - messageId, - }); - } catch (error) { - this.#debug("progress.cleanup.deleteFailed", { - queueId: active.queueItemId, - origin: active.origin, - turnId, - messageId, - error: errorMessage(error), - }); - } - } - if (deletedMessageIds.size > 0) { - for (const delivery of progressDeliveries) { - delivery.outboundMessageIds = delivery.outboundMessageIds.filter( - (messageId) => !deletedMessageIds.has(messageId), - ); - } - } - return deletedMessageIds.size > 0; - } - - async #startTypingHeartbeat(active: DiscordBridgeActiveTurn): Promise { - this.#stopTypingHeartbeat(); - this.#typingTurnKey = turnKey(active.codexThreadId, active.turnId); - await this.#context.presenter.sendTyping(active.discordThreadId); - const intervalMs = - this.#context.config.typingIntervalMs ?? defaultTypingIntervalMs; - this.#debug("typing.start", { - queueId: active.queueItemId, - origin: active.origin, - turnId: active.turnId, - intervalMs, - }); - const timer = setInterval(() => { - void this.#enqueue("typing.tick", async () => { - await this.#context.presenter.sendTyping(active.discordThreadId); - this.#debug("typing.tick", { - turnId: active.turnId, - }); - }).catch((error) => { - this.#debug("typing.error", { - turnId: active.turnId, - error: errorMessage(error), - }); - }); - }, intervalMs); - timer.unref?.(); - this.#typingTimer = timer; - } - - #stopTypingHeartbeat(): void { - if (!this.#typingTimer) { - return; - } - clearInterval(this.#typingTimer); - this.#typingTimer = undefined; - this.#debug("typing.stop", { - key: this.#typingTurnKey, - }); - this.#typingTurnKey = undefined; - } - - #scheduleActiveTurnReconcile(active: DiscordBridgeActiveTurn): void { - this.#clearReconcileTimer(); - const intervalMs = - this.#context.config.reconcileIntervalMs ?? defaultReconcileIntervalMs; - const timer = setTimeout(() => { - this.#reconcileTimer = undefined; - void this.#enqueue("turn.reconcile", async () => { - await this.#reconcileActiveTurn(); - }); - }, intervalMs); - timer.unref?.(); - this.#reconcileTimer = timer; - this.#debug("turn.reconcile.scheduled", { - queueId: active.queueItemId, - origin: active.origin, - turnId: active.turnId, - intervalMs, - }); - } - - async #reconcileActiveTurn(): Promise { - const active = this.#activeTurn(); - if (!active) { - return; - } - const turn = await this.#readTurn(active.turnId); - if (!turn) { - this.#scheduleActiveTurnReconcile(active); - return; - } - if (turn.status === "completed") { - await this.#completeTurn(active.codexThreadId, active.turnId, turn); - return; - } - if (turn.status === "failed" || turn.status === "interrupted") { - await this.#completeFailedTurn(active, turn.status); - await this.#processQueue(); - return; - } - this.#scheduleActiveTurnReconcile(active); - } - - #clearReconcileTimer(): void { - if (!this.#reconcileTimer) { - return; - } - clearTimeout(this.#reconcileTimer); - this.#reconcileTimer = undefined; - } - - async #ensureStatusMessage(): Promise { - const text = this.#renderStatusMessage(); - if (this.session.statusMessageId) { - await this.#updateStatusMessage(); - await this.#pinStatusMessage(this.session.statusMessageId); - return; - } - const [messageId] = await this.#context.presenter.sendMessage( - this.session.discordThreadId, - text, - ); - if (!messageId) { - return; - } - this.session.statusMessageId = messageId; - await this.#pinStatusMessage(messageId); - await this.#context.persist(); - this.#debug("status.message.created", { - messageId, - textLength: text.length, - }); - } - - async #updateStatusMessage(): Promise { - const messageId = this.session.statusMessageId; - if (!messageId) { - await this.#ensureStatusMessage(); - return; - } - if (!this.#context.presenter.updateMessage) { - return; - } - try { - const text = this.#renderStatusMessage(); - await this.#context.presenter.updateMessage( - this.session.discordThreadId, - messageId, - text, - ); - this.#debug("status.message.updated", { - messageId, - textLength: text.length, - }); - } catch (error) { - this.#debug("status.message.updateFailed", { - messageId, - error: errorMessage(error), - }); - this.session.statusMessageId = undefined; - await this.#context.persist(); - await this.#ensureStatusMessage(); - } - } - - async #pinStatusMessage(messageId: string): Promise { - if (!this.#context.presenter.pinMessage) { - return; - } - if (this.#pinnedStatusMessageId === messageId) { - return; - } - try { - await this.#context.presenter.pinMessage( - this.session.discordThreadId, - messageId, - ); - this.#pinnedStatusMessageId = messageId; - this.#debug("status.message.pinned", { messageId }); - } catch (error) { - this.#debug("status.message.pinFailed", { - messageId, - error: errorMessage(error), - }); - } - } - - async #refreshGoal(): Promise { - try { - const response = await this.#context.client.getThreadGoal({ - threadId: this.session.codexThreadId, - }); - this.#goal = response.goal ? runtimeGoal(response.goal) : undefined; - } catch (error) { - this.#debug("goal.refresh.failed", { - error: errorMessage(error), - }); - } - } - - #renderStatusMessage(): string { - return renderStatusMessage({ - session: this.session, - config: this.#context.config, - activeTurn: this.#activeTurn(), - activeItem: this.#activeProcessingItem(), - pendingCount: this.#sessionQueueItems().filter((item) => item.status === "pending").length, - failedCount: this.#sessionQueueItems().filter((item) => item.status === "failed").length, - goal: this.#goal, - planExplanation: this.#planExplanation, - planSteps: this.#planSteps, - planText: [...this.#planTextBuffers.values()].join("\n").trim(), - runningCommands: [...this.#runningCommands.values()].filter((command) => - this.#visibleRunningCommand(command) - ), - activities: [...this.#activities.values()], - }); - } - - #visibleRunningCommand(command: RunningCommand): boolean { - const startedAtMs = Date.parse(command.startedAt); - return Number.isFinite(startedAtMs) && - this.#context.now().getTime() - startedAtMs >= runningCommandStatusDelayMs; - } - - #clearRuntimeState(): void { - this.#planExplanation = undefined; - this.#planSteps = []; - this.#planTextBuffers.clear(); - this.#runningCommands.clear(); - this.#activities.clear(); - this.#clearRunningCommandStatusTimers(); - } - - #recordDeliveryForTurn( - active: DiscordBridgeActiveTurn, - kind: DiscordBridgeDelivery["kind"], - outboundMessageIds: string[], - ): void { - const item = this.#processingItemForTurn(active.turnId); - this.#state().deliveries.push({ - discordMessageId: item?.discordMessageId ?? `external:${active.turnId}`, - discordThreadId: active.discordThreadId, - codexThreadId: active.codexThreadId, - turnId: active.turnId, - kind, - outboundMessageIds, - deliveredAt: this.#context.now().toISOString(), - }); - this.#debug("delivery.recorded", { - discordMessageId: item?.discordMessageId ?? `external:${active.turnId}`, - origin: active.origin, - kind, - outboundMessageIds, - turnId: active.turnId, - }); - } - - #hasDelivery(turnId: string, kind: DiscordBridgeDelivery["kind"]): boolean { - return this.#state().deliveries.some( - (delivery) => - (this.session.mode === "operator" || - delivery.discordThreadId === this.session.discordThreadId) && - delivery.codexThreadId === this.session.codexThreadId && - delivery.turnId === turnId && - delivery.kind === kind, - ); - } - - #scheduleRetry(itemId: string, delayMs: number): void { - const existing = this.#retryTimers.get(itemId); - if (existing) { - clearTimeout(existing); - } - const timer = setTimeout(() => { - this.#retryTimers.delete(itemId); - void this.#enqueue("retry.fire", async () => { - await this.#processQueue(); - }); - }, Math.max(0, delayMs)); - timer.unref?.(); - this.#retryTimers.set(itemId, timer); - } - - #activeProcessingItem(): DiscordBridgeQueueItem | undefined { - return this.#sessionQueueItems().find((item) => item.status === "processing"); - } - - #activeTurn(): DiscordBridgeActiveTurn | undefined { - return this.#sessionActiveTurns()[0]; - } - - #activeTurnForTurn(turnId: string): DiscordBridgeActiveTurn | undefined { - return this.#sessionActiveTurns().find((active) => active.turnId === turnId); - } - - #sessionActiveTurns(): DiscordBridgeActiveTurn[] { - return this.#state().activeTurns.filter( - (active) => - (this.session.mode === "operator" || - active.discordThreadId === this.session.discordThreadId) && - active.codexThreadId === this.session.codexThreadId, - ); - } - - #upsertActiveTurn(input: { - turnId: string; - origin: DiscordBridgeActiveTurn["origin"]; - queueItemId?: string; - startedAt?: string; - discordThreadId?: string; - }): DiscordBridgeActiveTurn { - const state = this.#state(); - const observedAt = this.#context.now().toISOString(); - state.activeTurns = state.activeTurns.filter( - (active) => - (this.session.mode !== "operator" && - active.discordThreadId !== this.session.discordThreadId) || - active.codexThreadId !== this.session.codexThreadId || - active.turnId === input.turnId, - ); - const existing = this.#activeTurnForTurn(input.turnId); - if (existing) { - existing.origin = input.origin === "discord" ? "discord" : existing.origin; - existing.queueItemId = input.queueItemId ?? existing.queueItemId; - existing.startedAt = input.startedAt ?? existing.startedAt; - existing.discordThreadId = input.discordThreadId ?? existing.discordThreadId; - existing.observedAt = observedAt; - return existing; - } - const active: DiscordBridgeActiveTurn = { - turnId: input.turnId, - discordThreadId: input.discordThreadId ?? this.session.discordThreadId, - codexThreadId: this.session.codexThreadId, - origin: input.origin, - queueItemId: input.queueItemId, - startedAt: input.startedAt, - observedAt, - }; - state.activeTurns.push(active); - return active; - } - - #removeActiveTurn(turnId: string): void { - const state = this.#state(); - state.activeTurns = state.activeTurns.filter( - (active) => - (this.session.mode !== "operator" && - active.discordThreadId !== this.session.discordThreadId) || - active.codexThreadId !== this.session.codexThreadId || - active.turnId !== turnId, - ); - } - - #progressCleanupTarget(turnId: string): DiscordBridgeActiveTurn { - return { - turnId, - discordThreadId: this.session.discordThreadId, - codexThreadId: this.session.codexThreadId, - origin: "external", - observedAt: this.#context.now().toISOString(), - }; - } - - #processingItemForTurn(turnId: string): DiscordBridgeQueueItem | undefined { - return this.#sessionQueueItems().find( - (item) => item.status === "processing" && item.turnId === turnId, - ); - } - - #sessionQueueItems(): DiscordBridgeQueueItem[] { - return this.#state().queue.filter( - (item) => - (this.session.mode === "operator" || - item.discordThreadId === this.session.discordThreadId) && - item.codexThreadId === this.session.codexThreadId, - ); - } - - #removeQueueItem(item: DiscordBridgeQueueItem): void { - const state = this.#state(); - state.queue = state.queue.filter((candidate) => candidate !== item); - } - - #state(): DiscordBridgeState { - return this.#context.getState(); - } - - #progressMode(): "summary" | "commentary" | "none" { - return this.#context.config.progressMode ?? "summary"; - } - - #formatPrompt(item: DiscordBridgeQueueItem): string { - return formatDiscordPrompt(item, this.session, this.#context.config); - } - - #emitConsoleMessage( - kind: DiscordConsoleMessageKind, - turnId: string | undefined, - text: string, - ): void { - try { - this.#context.consoleOutput?.message({ - kind, - text, - discordThreadId: this.session.discordThreadId, - codexThreadId: this.session.codexThreadId, - turnId, - title: this.session.title, - at: this.#context.now(), - }); - } catch (error) { - this.#debug("console.message.failed", { - kind, - turnId, - error: errorMessage(error), - }); - } - } - - #cwd(): string | undefined { - return this.session.cwd ?? this.#context.config.cwd; - } - - #debug(event: string, fields: Record = {}): void { - this.#context.debug(event, { - discordThreadId: this.session.discordThreadId, - codexThreadId: this.session.codexThreadId, - ...fields, - }); - } -} - -export class MessageDeduplicator { - #seen = new Map(); - #ttlMs: number; - #maxSize: number; - #now: () => Date; - - constructor(options: { ttlMs?: number; maxSize?: number; now: () => Date }) { - this.#ttlMs = options.ttlMs ?? 300_000; - this.#maxSize = options.maxSize ?? 2_000; - this.#now = options.now; - } - - isDuplicate(id: string): boolean { - if (!id) { - return false; - } - const now = this.#now().getTime(); - const seenAt = this.#seen.get(id); - if (seenAt !== undefined && now - seenAt < this.#ttlMs) { - return true; - } - this.#seen.set(id, now); - if (this.#seen.size > this.#maxSize) { - const cutoff = now - this.#ttlMs; - for (const [candidate, timestamp] of this.#seen) { - if (timestamp <= cutoff) { - this.#seen.delete(candidate); - } - } - if (this.#seen.size > this.#maxSize) { - const newest = [...this.#seen.entries()] - .sort((left, right) => left[1] - right[1]) - .slice(-this.#maxSize); - this.#seen = new Map(newest); - } - } - return false; - } -} - -type SummaryKeyParts = { - threadId: string; - turnId: string; - itemId: string; - summaryIndex: number; -}; - -type AgentMessageKeyParts = { - threadId: string; - turnId: string; - itemId: string; -}; - -type RuntimeGoal = { - objective: string; - status: string; -}; - -type RuntimePlanStep = { - step: string; - status: "pending" | "inProgress" | "completed"; -}; - -type RunningCommand = { - itemId: string; - command: string; - status: "inProgress"; - startedAt: string; - lastOutputAt?: string; -}; - -type RuntimeActivity = { - itemId: string; - turnId: string; - kind: string; - label: string; - status: "inProgress" | "completed" | "failed" | "declined"; - updatedAt: string; -}; - -type StatusRenderInput = { - session: DiscordBridgeSession; - config: DiscordBridgeConfig; - activeTurn?: DiscordBridgeActiveTurn; - activeItem?: DiscordBridgeQueueItem; - pendingCount: number; - failedCount: number; - goal?: RuntimeGoal; - planExplanation?: string; - planSteps: RuntimePlanStep[]; - planText: string; - runningCommands: RunningCommand[]; - activities: RuntimeActivity[]; -}; - -function renderStatusMessage(input: StatusRenderInput): string { - const lines = [ - "**Codex Discord Bridge**", - `Mode: \`${input.session.mode ?? "new"}\``, - `Codex thread: \`${input.session.codexThreadId}\``, - `Dir: \`${input.session.cwd ?? input.config.cwd ?? "default"}\``, - `Progress: \`${input.config.progressMode ?? "summary"}\``, - `Model: \`${input.config.model ?? "default"}\``, - `Permissions: ${permissionSummary(input.config)}`, - "", - "**Access**", - `Owner: ${mentionUser(input.session.ownerUserId)}`, - `Participants: ${mentionUsers(input.session.participantUserIds ?? [])}`, - `Global admins: ${mentionUsers([...input.config.allowedUserIds])}`, - "", - "**Turn**", - `Status: ${turnStatus(input.activeTurn, input.activeItem)}`, - `Queue: ${input.pendingCount} pending, ${input.failedCount} failed`, - `Goal: ${goalSummary(input.goal)}`, - "", - "**Plan**", - ...planLines(input), - "", - "**Running Commands**", - ...runningCommandLines(input.runningCommands), - "", - "**Activity**", - ...activityLines(input.activities), - ]; - const text = lines.join("\n"); - return text.length <= 1900 ? text : `${text.slice(0, 1897).trimEnd()}...`; -} - -function permissionSummary(config: DiscordBridgeConfig): string { - const parts = [ - `approval \`${config.approvalPolicy ?? "default"}\``, - `permission profile \`${permissionProfileLabel(config.permissions)}\``, - `sandbox \`${config.sandbox ?? "default"}\``, - ]; - return parts.join(", "); -} - -function permissionProfileLabel( - permissions: DiscordBridgeConfig["permissions"], -): string { - if (!permissions) { - return "default"; - } - return permissions; -} - -function turnStatus( - active: DiscordBridgeActiveTurn | undefined, - item: DiscordBridgeQueueItem | undefined, -): string { - if (!active) { - return "`idle`"; - } - const queue = item ? `, queue \`${item.status}\`` : ""; - return `\`inProgress\`, origin \`${active.origin}\`, turn \`${compactId(active.turnId)}\`${queue}`; -} - -function goalSummary(goal: RuntimeGoal | undefined): string { - if (!goal) { - return "none"; - } - return `\`${goal.status}\` ${truncateOneLine(goal.objective, 160)}`; -} - -function planLines(input: StatusRenderInput): string[] { - if (input.planSteps.length > 0) { - return input.planSteps.slice(0, 8).map((step) => - `- \`${step.status}\` ${truncateOneLine(step.step, 160)}` - ); - } - if (input.planText) { - return input.planText.split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .slice(0, 8) - .map((line) => `- ${truncateOneLine(line, 160)}`); - } - if (input.planExplanation) { - return [`- ${truncateOneLine(input.planExplanation, 160)}`]; - } - return ["none"]; -} - -function runningCommandLines(commands: RunningCommand[]): string[] { - if (commands.length === 0) { - return ["none"]; - } - return commands.slice(0, 8).map((command) => - `- \`${truncateOneLine(command.command, 140)}\`` - ); -} - -function activityLines(activities: RuntimeActivity[]): string[] { - if (activities.length === 0) { - return ["none"]; - } - return activities - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) - .slice(0, 6) - .map((activity) => - `- \`${activity.status}\` ${activity.kind}: ${truncateOneLine(activity.label, 120)}` - ); -} - -function mentionUser(userId: string | undefined): string { - return userId ? `<@${userId}>` : "unknown"; -} - -function mentionUsers(userIds: string[]): string { - return userIds.length > 0 - ? userIds.map((userId) => `<@${userId}>`).join(", ") - : "none"; -} - -function runtimeGoal(value: Record): RuntimeGoal | undefined { - const objective = stringValue(value.objective); - if (!objective) { - return undefined; - } - return { - objective, - status: stringValue(value.status) ?? "active", - }; -} - -function planStepStatus(value: unknown): RuntimePlanStep["status"] { - return value === "pending" || value === "inProgress" || value === "completed" - ? value - : "pending"; -} - -function commandStatus(value: unknown): "inProgress" | "completed" | "failed" | "declined" { - return value === "inProgress" || - value === "completed" || - value === "failed" || - value === "declined" - ? value - : "inProgress"; -} - -function activityFromItem( - item: Record, - turnId: string, - fallbackStatus: RuntimeActivity["status"], - now: Date, -): RuntimeActivity | undefined { - const itemId = stringValue(item.id); - if (!itemId) { - return undefined; - } - const status = activityStatus(item.status, fallbackStatus); - const base = { - itemId, - turnId, - status, - updatedAt: now.toISOString(), - }; - if (item.type === "fileChange") { - const changes = Array.isArray(item.changes) ? item.changes.length : 0; - return { - ...base, - kind: "files", - label: changes > 0 ? `${changes} file change${changes === 1 ? "" : "s"}` : "file changes", - }; - } - if (item.type === "mcpToolCall") { - const server = stringValue(item.server) ?? "mcp"; - const tool = stringValue(item.tool) ?? "tool"; - return { - ...base, - kind: "mcp", - label: `${server}.${tool}`, - }; - } - if (item.type === "dynamicToolCall") { - const namespace = stringValue(item.namespace); - const tool = stringValue(item.tool) ?? "tool"; - return { - ...base, - kind: "tool", - label: namespace ? `${namespace}.${tool}` : tool, - }; - } - if (item.type === "collabAgentToolCall") { - return { - ...base, - kind: "agent", - label: stringValue(item.tool) ?? "collab agent", - }; - } - if (item.type === "webSearch") { - return { - ...base, - kind: "web", - label: stringValue(item.query) ?? "web search", - }; - } - if (item.type === "imageGeneration") { - return { - ...base, - kind: "image", - label: "image generation", - }; - } - if (item.type === "contextCompaction") { - return { - ...base, - kind: "context", - label: "compaction", - }; - } - return undefined; -} - -function activityStatus( - value: unknown, - fallback: RuntimeActivity["status"], -): RuntimeActivity["status"] { - return value === "inProgress" || - value === "completed" || - value === "failed" || - value === "declined" - ? value - : fallback; -} - -function truncateOneLine(value: string, maxLength: number): string { - const oneLine = value.trim().replace(/\s+/g, " "); - if (oneLine.length <= maxLength) { - return oneLine; - } - return `${oneLine.slice(0, maxLength - 3).trimEnd()}...`; -} - -function formatDiscordPrompt( - item: DiscordBridgeQueueItem, - session: DiscordBridgeSession, - config: DiscordBridgeConfig, -): string { - if (session.mode === "operator") { - const surface = config.workspace?.surfaces?.find((candidate) => - candidate.homeChannelId === item.discordThreadId - ); - return [ - "[discord-workspace]", - "Role: You are the main Codex operator thread for a configured Discord workspace surface.", - "Intent: Treat this as a workspace request. Answer directly when appropriate; otherwise reason about backend/runtime delegation without assuming Discord itself owns a workspace registry.", - "Canonical memory: This main Codex thread is the operator memory. Delegated Codex threads remain canonical history for delegated work.", - surface ? `Surface: ${surface.key}` : undefined, - `Author: ${item.authorName} (${item.authorId})`, - `Message: ${item.discordMessageId}`, - `Home channel: ${item.discordThreadId}`, - `Workspace cwd: ${session.cwd ?? "default"}`, - "", - item.content, - ].filter((line): line is string => line !== undefined).join("\n"); - } - return [ - "[discord]", - `Author: ${item.authorName} (${item.authorId})`, - `Message: ${item.discordMessageId}`, - `Discord thread: ${item.discordThreadId}`, - "", - item.content, - ].join("\n"); -} - -function retryDelayMs(item: DiscordBridgeQueueItem, now: Date): number { - if (!item.nextAttemptAt) { - return 0; - } - return Math.max(0, new Date(item.nextAttemptAt).getTime() - now.getTime()); -} - -function backoffMs(attempts: number): number { - return Math.min(30_000, 1000 * 2 ** Math.max(0, attempts - 1)); -} - -function turnKey(threadId: string, turnId: string): string { - return `${threadId}/${turnId}`; -} - -function compactId(value: string): string { - return value.length > 14 ? `${value.slice(0, 6)}...${value.slice(-6)}` : value; -} - -function summaryNotificationKey( - threadId: string, - turnId: string, - params: Record, -): SummaryKeyParts { - return { - threadId, - turnId, - itemId: stringValue(params.itemId) ?? "reasoning", - summaryIndex: numberValue(params.summaryIndex) ?? 0, - }; -} - -function summaryKeyString(parts: SummaryKeyParts): string { - return JSON.stringify([ - parts.threadId, - parts.turnId, - parts.itemId, - parts.summaryIndex, - ]); -} - -function summaryKeyParts(key: string): SummaryKeyParts { - const parsed = JSON.parse(key) as unknown; - if (!Array.isArray(parsed)) { - throw new Error("Invalid summary key"); - } - return { - threadId: String(parsed[0] ?? ""), - turnId: String(parsed[1] ?? ""), - itemId: String(parsed[2] ?? "reasoning"), - summaryIndex: typeof parsed[3] === "number" ? parsed[3] : 0, - }; -} - -function agentMessageKey(parts: AgentMessageKeyParts): string { - return JSON.stringify([parts.threadId, parts.turnId, parts.itemId]); -} - -function agentMessageKeyParts(key: string): AgentMessageKeyParts { - const parsed = JSON.parse(key) as unknown; - if (!Array.isArray(parsed)) { - throw new Error("Invalid agent message key"); - } - return { - threadId: String(parsed[0] ?? ""), - turnId: String(parsed[1] ?? ""), - itemId: String(parsed[2] ?? "agent-message"), - }; -} - -function messagePhase(value: unknown): "commentary" | "final_answer" | undefined { - return value === "commentary" || value === "final_answer" ? value : undefined; -} - -function turnStartedAt(turn: Record): string | undefined { - const startedAt = numberValue(turn.startedAt); - return startedAt === undefined - ? undefined - : new Date(startedAt * 1000).toISOString(); -} - -function isActiveTurn( - value: DiscordBridgeActiveTurn | DiscordBridgeQueueItem, -): value is DiscordBridgeActiveTurn { - return "origin" in value; -} - -function isDuplicate(state: DiscordBridgeState, messageId: string): boolean { - return ( - state.processedMessageIds.includes(messageId) || - state.queue.some((item) => item.discordMessageId === messageId) || - state.deliveries.some((delivery) => delivery.discordMessageId === messageId) - ); -} - -function addProcessedMessageId(state: DiscordBridgeState, messageId: string): void { - state.processedMessageIds = [ - ...state.processedMessageIds.filter((candidate) => candidate !== messageId), - messageId, - ].slice(-1000); -} - -function finalTextFromTurn(turn: Record): string { - const items = Array.isArray(turn.items) ? turn.items : []; - const agentMessages = items - .filter(isRecord) - .filter((item) => item.type === "agentMessage"); - const finalMessages = agentMessages.filter( - (item) => item.phase === "final_answer", - ); - const selected = finalMessages.length > 0 ? finalMessages : agentMessages; - return selected - .map((item) => stringValue(item.text) ?? "") - .filter(Boolean) - .join("\n\n"); -} - -function record(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -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; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/apps/discord-bridge/src/state.ts b/apps/discord-bridge/src/state.ts deleted file mode 100644 index c739350..0000000 --- a/apps/discord-bridge/src/state.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - return structuredClone(this.state); - } - - async save(state: DiscordBridgeState): Promise { - 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[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 { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/apps/discord-bridge/src/stop-hook-spool.ts b/apps/discord-bridge/src/stop-hook-spool.ts deleted file mode 100644 index 707816e..0000000 --- a/apps/discord-bridge/src/stop-hook-spool.ts +++ /dev/null @@ -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 { - 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 { - return await writeHookSpoolEvent(input, options); -} - -export async function writeHookSpoolEvent( - input: unknown, - options: { - spoolDir?: string; - now?: () => Date; - } = {}, -): Promise { - 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 { - 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, - spoolDir: string, - disposition: HookEventSpoolDisposition, -): Promise { - 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 { - 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 { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? value as Record - : {}; -} - -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; -} diff --git a/apps/discord-bridge/src/stop-hook.ts b/apps/discord-bridge/src/stop-hook.ts deleted file mode 100644 index 31c2ec3..0000000 --- a/apps/discord-bridge/src/stop-hook.ts +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -import { runStopHook } from "./hook-cli.ts"; - -await runStopHook(); diff --git a/apps/discord-bridge/src/types.ts b/apps/discord-bridge/src/types.ts deleted file mode 100644 index 37c073b..0000000 --- a/apps/discord-bridge/src/types.ts +++ /dev/null @@ -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; - allowedChannelIds: Set; - 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; -}; - -export type DiscordClearInbound = { - kind: "clear"; - channelId: string; - guildId?: string; - author: DiscordAuthor; - createdAt: string; - reply?: (text: string) => Promise; -}; - -export type DiscordClearWebhooksInbound = { - kind: "clearWebhooks"; - channelId: string; - guildId?: string; - author: DiscordAuthor; - webhookUrl?: string; - createdAt: string; - reply?: (text: string) => Promise; -}; - -export type DiscordStatusInbound = { - kind: "status"; - channelId: string; - guildId?: string; - author: DiscordAuthor; - createdAt: string; - reply?: (text: string) => Promise; - replyPicker?: (picker: DiscordEphemeralPicker) => Promise; -}; - -export type DiscordThreadsInbound = { - kind: "threads"; - channelId: string; - guildId?: string; - author: DiscordAuthor; - createdAt: string; - reply?: (text: string) => Promise; - replyPicker?: (picker: DiscordEphemeralPicker) => Promise; -}; - -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; - replyPicker?: (picker: DiscordEphemeralPicker) => Promise; -}; - -export type DiscordThreadPickerInbound = { - kind: "threadPicker"; - channelId: string; - guildId?: string; - pickerId: string; - optionId: string; - author: DiscordAuthor; - createdAt: string; - reply?: (text: string) => Promise; - update?: (text: string) => Promise; - updatePicker?: (picker: DiscordEphemeralPicker) => Promise; -}; - -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; - stop(): Promise; - registerCommands(options?: DiscordBridgeCommandRegistration): Promise; - createForumPost?( - channelId: string, - name: string, - message: string, - ): Promise<{ threadId: string; messageId?: string }>; - createThread( - channelId: string, - name: string, - sourceMessageId?: string, - ): Promise; - sendMessage(channelId: string, text: string): Promise; - updateMessage?( - channelId: string, - messageId: string, - text: string, - ): Promise; - deleteMessage(channelId: string, messageId: string): Promise; - deleteWebhookMessages?( - channelId: string, - options?: { webhookUrl?: string }, - ): Promise<{ deleted: number; failed: number }>; - deleteThread?(channelId: string): Promise; - addThreadMembers?(channelId: string, userIds: string[]): Promise; - addReactions?(channelId: string, messageId: string, reactions: string[]): Promise; - pinMessage?(channelId: string, messageId: string): Promise; - sendTyping(channelId: string): Promise; -}; - -export type CodexBridgeClient = { - connect(): Promise; - close(): void; - on(event: "notification", listener: (message: JsonRpcNotification) => void): unknown; - on(event: "request", listener: (message: JsonRpcRequest) => void): unknown; - startThread(params: v2.ThreadStartParams): Promise; - resumeThread(params: v2.ThreadResumeParams): Promise; - setThreadName(params: v2.ThreadSetNameParams): Promise; - startTurn(params: v2.TurnStartParams): Promise; - steerTurn(params: v2.TurnSteerParams): Promise; - readThread(params: v2.ThreadReadParams): Promise; - injectThreadItems(params: v2.ThreadInjectItemsParams): Promise; - listThreads(params: v2.ThreadListParams): Promise; - setThreadGoal(params: v2.ThreadGoalSetParams): Promise; - getThreadGoal(params: v2.ThreadGoalGetParams): Promise; - clearThreadGoal(params: v2.ThreadGoalClearParams): Promise; - 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; - save(state: DiscordBridgeState): Promise; -}; diff --git a/apps/discord-bridge/src/workspace-backend.ts b/apps/discord-bridge/src/workspace-backend.ts deleted file mode 100644 index 0362b9f..0000000 --- a/apps/discord-bridge/src/workspace-backend.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { - DiscordBridgeCommandRegistration, - DiscordBridgeState, - DiscordInbound, -} from "./types.ts"; - -export type CodexWorkspaceBackend = { - start(): Promise; - startTransportDependentWork?(): Promise; - startBackgroundWork?(): Promise; - stop(): Promise; - handleInbound(inbound: DiscordInbound): Promise; - commandRegistration(): DiscordBridgeCommandRegistration; - stateForTest?(): DiscordBridgeState; - flushSummariesForTest?(): Promise; -}; - -export type CodexWorkspacePresenter = { - createWorkspacePost?( - locationId: string, - title: string, - body: string, - ): Promise<{ threadId: string; messageId?: string }>; - createThread( - locationId: string, - title: string, - sourceMessageId?: string, - ): Promise; - sendMessage(locationId: string, text: string): Promise; - updateMessage?( - locationId: string, - messageId: string, - text: string, - ): Promise; - deleteMessage(locationId: string, messageId: string): Promise; - deleteWebhookMessages?( - locationId: string, - options?: { webhookUrl?: string }, - ): Promise<{ deleted: number; failed: number }>; - deleteThread?(locationId: string): Promise; - addThreadMembers?(threadId: string, userIds: string[]): Promise; - addReactions?(locationId: string, messageId: string, reactions: string[]): Promise; - pinMessage?(locationId: string, messageId: string): Promise; - sendTyping(locationId: string): Promise; -}; diff --git a/apps/discord-bridge/test/bridge.test.ts b/apps/discord-bridge/test/bridge.test.ts deleted file mode 100644 index 51b548d..0000000 --- a/apps/discord-bridge/test/bridge.test.ts +++ /dev/null @@ -1,5016 +0,0 @@ -import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; -import { createHash } from "node:crypto"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, test } from "vite-plus/test"; -import type { JsonRpcNotification, JsonRpcRequest } from "@peezy.tech/codex-flows/rpc"; -import type { v2 } from "@peezy.tech/codex-flows/generated"; - -import { - DiscordCodexBridge, - LocalCodexWorkspaceBackend, - parseThreadStartIntent, -} from "../src/bridge.ts"; -import type { - DiscordConsoleMessage, - DiscordConsoleOutput, -} from "../src/console-output.ts"; -import { MemoryStateStore, emptyState } from "../src/state.ts"; -import { writeStopHookSpoolEvent } from "../src/stop-hook-spool.ts"; -import type { - CodexWorkspaceBackend, - CodexWorkspacePresenter, -} from "../src/workspace-backend.ts"; -import type { - CodexBridgeClient, - DiscordBridgeConfig, - DiscordBridgeCommandRegistration, - DiscordBridgeTransport, - DiscordBridgeTransportHandlers, - DiscordEphemeralPicker, - DiscordInbound, -} from "../src/types.ts"; - -describe("DiscordCodexBridge", () => { - test("parses mention control text for resume and per-thread directories", () => { - expect(parseThreadStartIntent("resume codex-thread-123 --dir ~/project")).toEqual({ - kind: "resume", - codexThreadId: "codex-thread-123", - cwd: path.join(os.homedir(), "project"), - }); - expect(parseThreadStartIntent("--dir projects/demo inspect this")).toEqual({ - kind: "new", - prompt: "inspect this", - cwd: path.join(os.homedir(), "projects/demo"), - }); - expect(parseThreadStartIntent("resume")).toEqual({ - kind: "invalid", - message: "Usage: @codex resume [--dir path]", - }); - }); - - test("can run Discord as a transport over a workspace backend", async () => { - const transport = new FakeDiscordTransport(); - const calls: string[] = []; - const inboundEvents: DiscordInbound[] = []; - const backend: CodexWorkspaceBackend = { - async start() { - calls.push("backend.start"); - }, - async startTransportDependentWork() { - calls.push("backend.transportWork"); - }, - async startBackgroundWork() { - calls.push("backend.backgroundWork"); - }, - async stop() { - calls.push("backend.stop"); - }, - async handleInbound(inbound) { - inboundEvents.push(inbound); - }, - commandRegistration() { - return { channelIds: ["home-channel"] }; - }, - stateForTest() { - return emptyState(); - }, - }; - const bridge = new DiscordCodexBridge({ - backend, - transport, - }); - - await bridge.start(); - expect(calls).toEqual([ - "backend.start", - "backend.transportWork", - "backend.backgroundWork", - ]); - expect(transport.registeredCommands).toEqual([ - { channelIds: ["home-channel"] }, - ]); - - transport.emit({ - kind: "message", - channelId: "home-channel", - messageId: "message-1", - author: { id: "user-1", name: "Peezy", isBot: false }, - content: "status", - createdAt: "2026-05-15T00:00:00.000Z", - }); - await waitFor(() => inboundEvents.length === 1); - expect(inboundEvents[0]?.kind).toBe("message"); - - await bridge.stop(); - expect(calls).toContain("backend.stop"); - }); - - test("local workspace backend runs against a presenter without Discord transport lifecycle", async () => { - const client = new FakeCodexClient(); - const sentMessages: Array<{ locationId: string; text: string }> = []; - const typingLocations: string[] = []; - const presenter: CodexWorkspacePresenter = { - async createThread(locationId, title, sourceMessageId) { - expect(locationId).toBe("parent-channel"); - expect(title).toBe("Existing thread"); - expect(sourceMessageId).toBe("source-message-1"); - return "presenter-thread-1"; - }, - async sendMessage(locationId, text) { - sentMessages.push({ locationId, text }); - return [`presenter-message-${sentMessages.length}`]; - }, - async deleteMessage() {}, - async sendTyping(locationId) { - typingLocations.push(locationId); - }, - }; - const backend = new LocalCodexWorkspaceBackend({ - client, - presenter, - store: new MemoryStateStore(), - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - allowedChannelIds: new Set(["parent-channel"]), - }), - }); - - await backend.start(); - expect(client.startThreadCalls).toHaveLength(1); - expect(backend.commandRegistration()).toEqual({ - channelIds: ["parent-channel", "home-channel"], - }); - - await backend.startTransportDependentWork(); - await backend.startBackgroundWork(); - await backend.handleInbound({ - kind: "message", - channelId: "home-channel", - messageId: "home-message-1", - author: { id: "user-1", name: "Peezy", isBot: false }, - content: "status across the workspaces", - createdAt: "2026-05-15T00:00:00.000Z", - }); - await waitFor(() => client.startTurnCalls.length === 1); - expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( - "status across the workspaces", - ); - expect(typingLocations).toContain("home-channel"); - - await backend.stop(); - }); - - test("starts a workspace main thread and routes home channel messages to it", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - allowedChannelIds: new Set(["parent-channel"]), - }), - }); - - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - expect(transport.registeredCommands).toEqual([ - { channelIds: ["parent-channel", "home-channel"] }, - ]); - expect(client.startThreadCalls).toHaveLength(1); - expect(client.startThreadCalls[0]?.dynamicTools).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - namespace: "codex_workspace", - name: "start_delegation", - }), - ]), - ); - expect(client.setThreadNameCalls[0]).toEqual({ - threadId: "codex-thread-1", - name: "[discord-workspace] Codex Workspace", - }); - expect(bridge.stateForTest().workspace).toEqual( - expect.objectContaining({ - homeChannelId: "home-channel", - mainThreadId: "codex-thread-1", - toolsVersion: 1, - }), - ); - expect(bridge.stateForTest().sessions[0]).toEqual( - expect.objectContaining({ - discordThreadId: "home-channel", - parentChannelId: "home-channel", - codexThreadId: "codex-thread-1", - title: "Codex Workspace", - cwd: "/workspace", - mode: "operator", - }), - ); - - transport.emit({ - kind: "message", - channelId: "home-channel", - messageId: "home-message-1", - author: { id: "user-1", name: "Peezy", isBot: false }, - content: "status across the workspaces", - createdAt: "2026-05-14T00:00:00.000Z", - }); - - await waitFor(() => client.startTurnCalls.length === 1); - expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( - "status across the workspaces", - ); - expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( - "[discord-workspace]", - ); - expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( - "main Codex operator thread", - ); - expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( - "Home channel: home-channel", - ); - await bridge.stop(); - }); - - test("workspace tool starts and tracks delegated Codex sessions without privileged tools", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - client.emitRequest({ - id: "tool-1", - method: "item/tool/call", - params: { - threadId: "codex-thread-1", - turnId: "turn-main", - callId: "call-1", - namespace: "codex_workspace", - tool: "start_delegation", - arguments: { - cwd: "/workspace/other", - title: "Other workspace", - prompt: "Inspect the remaining workspace work.", - discordDetailThreadId: "detail-thread", - parentDiscordMessageId: "home-message", - }, - }, - }); - - await waitFor(() => client.responses.length === 1); - expect(client.responseErrors).toEqual([]); - expect(client.startThreadCalls).toHaveLength(2); - expect(client.startThreadCalls[1]).toEqual( - expect.objectContaining({ cwd: "/workspace/other" }), - ); - expect(client.startThreadCalls[1]?.dynamicTools).toBeUndefined(); - expect(client.setThreadNameCalls[1]).toEqual({ - threadId: "codex-thread-2", - name: "[delegated] Other workspace", - }); - expect(client.startTurnCalls[0]).toEqual( - expect.objectContaining({ - threadId: "codex-thread-2", - cwd: "/workspace/other", - }), - ); - expect(inputText(client.startTurnCalls[0]?.input[0])).toBe( - "Inspect the remaining workspace work.", - ); - expect(bridge.stateForTest().workspace?.delegations).toEqual([ - expect.objectContaining({ - codexThreadId: "codex-thread-2", - title: "Other workspace", - status: "active", - cwd: "/workspace/other", - discordDetailThreadId: "detail-thread", - parentDiscordMessageId: "home-message", - }), - ]); - expect(workspaceToolResult(client.responses[0]?.result)).toEqual( - expect.objectContaining({ - turnId: "turn-1", - delegation: expect.objectContaining({ - codexThreadId: "codex-thread-2", - }), - }), - ); - await bridge.stop(); - }); - - test("workspace workbench opens delegation task threads lazily from workspace posts", async () => { - const hookSpoolDir = await testHookSpoolDir(); - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "discord-workbench-root-")); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - cwd: workspaceRoot, - workspace: { - homeChannelId: "home-channel", - workspaceForumChannelId: "workspace-forum", - taskThreadsChannelId: "task-channel", - }, - allowedChannelIds: new Set(["home-channel"]), - hookSpoolDir, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - client.emitRequest({ - id: "tool-1", - method: "item/tool/call", - params: { - threadId: "codex-thread-1", - namespace: "codex_workspace", - tool: "start_delegation", - arguments: { - cwd: "/workspace/codex-flows", - title: "Hook packaging", - prompt: "Package the hook command.", - returnMode: "record_only", - }, - }, - }); - - await waitFor(() => client.responses.length === 1); - expect(transport.createdForumPosts).toEqual([ - expect.objectContaining({ - channelId: "workspace-forum", - name: "codex-flows", - threadId: "forum-post-1", - }), - ]); - expect(transport.createdThreads).toEqual([]); - const state = bridge.stateForTest(); - expect(state.workspace?.workspaces).toEqual([ - expect.objectContaining({ - cwd: "/workspace/codex-flows", - title: "codex-flows", - discordThreadId: "forum-post-1", - statusMessageId: "forum-post-1", - delegationIds: [state.workspace?.delegations[0]?.id], - }), - ]); - expect(state.workspace?.delegations[0]).toEqual( - expect.objectContaining({ - codexThreadId: "codex-thread-2", - workspaceKey: state.workspace?.workspaces?.[0]?.key, - discordWorkspaceThreadId: "forum-post-1", - }), - ); - expect(state.workspace?.delegations[0]?.discordTaskThreadId).toBeUndefined(); - const workspaceUpdate = transport.updatedMessages.find((message) => - message.channelId === "forum-post-1" && - message.messageId === "forum-post-1" - ); - expect(workspaceUpdate?.text).toContain("**Visible Threads**"); - expect(workspaceUpdate?.text).toContain( - "1. `not opened` Hook packaging (active)", - ); - - const replies: string[] = []; - transport.emit({ - kind: "threads", - channelId: "forum-post-1", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-14T12:00:30.000Z", - reply: async (text) => { - replies.push(text); - }, - replyPicker: transport.threadsReplyPicker(), - }); - await waitFor(() => transport.ephemeralPickers.length === 1); - expect(replies).toEqual([]); - expect(transport.messages.some((message) => - message.channelId === "forum-post-1" && - message.text.includes("Hook packaging") - )).toBe(true); - const picker = transport.ephemeralPickers[0]; - expect(picker?.text).toContain("1️⃣ `not opened` Hook packaging"); - expect(picker?.text).toContain( - "Choose a number to open or resume that thread in Discord.", - ); - expect(picker?.options).toEqual([{ id: "0", label: "1" }]); - - transport.emitThreadPicker({ - pickerId: picker?.pickerId ?? "", - optionId: "0", - }); - await waitFor(() => transport.createdThreads.length === 1); - expect(transport.ephemeralUpdates.some((update) => - update.pickerId === picker?.pickerId && - update.text === "Opened Hook packaging: <#discord-thread-1>" - )).toBe(true); - expect(transport.createdThreads).toEqual([ - { - channelId: "task-channel", - name: "codex-flows: Hook packaging", - sourceMessageId: undefined, - }, - ]); - expect(bridge.stateForTest().sessions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - discordThreadId: "discord-thread-1", - parentChannelId: "task-channel", - codexThreadId: "codex-thread-2", - cwd: "/workspace/codex-flows", - mode: "workspace", - }), - ]), - ); - - await emitStopHook(hookSpoolDir, { - sessionId: "codex-thread-2", - turnId: "turn-1", - lastAssistantMessage: "Workbench result.", - cwd: "/workspace/codex-flows", - }); - await waitFor(() => client.injectThreadItemsCalls.length === 1); - expect(transport.messages.some((message) => - message.channelId === "discord-thread-1" && - message.text.includes("Workbench result.") - )).toBe(true); - const homeResult = transport.messages.find((message) => - message.channelId === "home-channel" && - message.text.includes("[discord-workspace delegation result]") && - message.text.includes("Hook packaging") - )?.text ?? ""; - expect(homeResult).toContain("<#forum-post-1>"); - expect(homeResult).toContain("<#discord-thread-1>"); - expect(homeResult).not.toContain("Workbench result."); - expect(transport.updatedMessages.some((message) => - message.channelId === "forum-post-1" && - message.text.includes("<#discord-thread-1> Hook packaging") - )).toBe(true); - - transport.emit({ - kind: "message", - channelId: "discord-thread-1", - messageId: "task-follow-up", - author: { id: "user-1", name: "Peezy", isBot: false }, - content: "Continue in this delegated thread.", - createdAt: "2026-05-14T12:01:00.000Z", - }); - await waitFor(() => client.startTurnCalls.length === 2); - expect(client.startTurnCalls[1]).toEqual( - expect.objectContaining({ - threadId: "codex-thread-2", - cwd: "/workspace/codex-flows", - }), - ); - expect(inputText(client.startTurnCalls[1]?.input[0])).toContain( - "Continue in this delegated thread.", - ); - } finally { - await bridge.stop(); - await rm(hookSpoolDir, { recursive: true, force: true }); - await rm(workspaceRoot, { recursive: true, force: true }); - } - }); - - test("workspace workbench reuses one workspace post per normalized cwd", async () => { - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "discord-workbench-root-")); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - cwd: workspaceRoot, - workspace: { - homeChannelId: "home-channel", - workspaceForumChannelId: "workspace-forum", - taskThreadsChannelId: "task-channel", - }, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - for (const [index, title] of ["First task", "Second task"].entries()) { - client.emitRequest({ - id: `tool-${index}`, - method: "item/tool/call", - params: { - threadId: "codex-thread-1", - namespace: "codex_workspace", - tool: "start_delegation", - arguments: { - cwd: index === 0 - ? "/workspace/codex-flows/." - : "/workspace/codex-flows", - title, - }, - }, - }); - await waitFor(() => client.responses.length === index + 1); - } - - expect(transport.createdForumPosts).toHaveLength(1); - expect(transport.createdThreads).toHaveLength(0); - const workspaces = bridge.stateForTest().workspace?.workspaces ?? []; - const delegations = bridge.stateForTest().workspace?.delegations ?? []; - expect(workspaces).toEqual([ - expect.objectContaining({ - cwd: "/workspace/codex-flows", - delegationIds: delegations.map((delegation) => delegation.id), - }), - ]); - expect(new Set(delegations.map((delegation) => delegation.workspaceKey)).size) - .toBe(1); - } finally { - await bridge.stop(); - await rm(workspaceRoot, { recursive: true, force: true }); - } - }); - - test("workspace workbench discovers top-level folders under the main workspace", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "discord-workspaces-")); - await mkdir(path.join(root, "alpha", "nested"), { recursive: true }); - await mkdir(path.join(root, "beta"), { recursive: true }); - await mkdir(path.join(root, ".cache"), { recursive: true }); - const client = new FakeCodexClient(); - client.threads = [ - testThread({ - id: "codex-alpha-existing", - cwd: path.join(root, "alpha", "nested"), - name: "Alpha existing", - updatedAt: 3, - }), - testThread({ - id: "codex-beta-existing", - cwd: path.join(root, "beta"), - name: "Beta existing", - updatedAt: 2, - }), - ]; - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - cwd: root, - workspace: { - homeChannelId: "home-channel", - workspaceForumChannelId: "workspace-forum", - taskThreadsChannelId: "task-channel", - }, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - expect(transport.createdForumPosts.map((post) => post.name)).toEqual([ - "alpha", - "beta", - ]); - expect(bridge.stateForTest().workspace?.workspaces?.map((workspace) => - workspace.cwd - )).toEqual([ - path.join(root, "alpha"), - path.join(root, "beta"), - ]); - expect(transport.updatedMessages.some((message) => - message.channelId === "forum-post-1" && - message.text.includes("**Visible Threads**\nNone") - )).toBe(true); - expect(transport.updatedMessages.some((message) => - message.channelId === "forum-post-2" && - message.text.includes("**Visible Threads**\nNone") - )).toBe(true); - const replies: string[] = []; - transport.emit({ - kind: "threads", - channelId: "forum-post-1", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-14T12:00:30.000Z", - reply: async (text) => { - replies.push(text); - }, - replyPicker: transport.threadsReplyPicker(), - }); - await waitFor(() => transport.ephemeralPickers.length === 1); - expect(replies).toEqual([]); - expect(transport.messages.some((message) => - message.channelId === "forum-post-1" && - message.text.includes("Alpha existing") - )).toBe(false); - const picker = transport.ephemeralPickers[0]; - expect(picker?.text).toContain("1️⃣ `not opened` Alpha existing"); - transport.emitThreadPicker({ - pickerId: picker?.pickerId ?? "", - optionId: "0", - }); - await waitFor(() => transport.createdThreads.length === 1); - expect(client.resumeThreadCalls.some((call) => - call.threadId === "codex-alpha-existing" - )).toBe(true); - expect(transport.createdThreads[0]).toEqual({ - channelId: "task-channel", - name: "alpha: Alpha existing", - sourceMessageId: undefined, - }); - - client.emitRequest({ - id: "tool-nested", - method: "item/tool/call", - params: { - threadId: "codex-thread-1", - namespace: "codex_workspace", - tool: "start_delegation", - arguments: { - cwd: path.join(root, "alpha", "nested", "project"), - title: "Nested task", - }, - }, - }); - await waitFor(() => client.responses.length === 1); - expect(transport.createdForumPosts).toHaveLength(2); - expect(transport.createdThreads).toHaveLength(1); - const state = bridge.stateForTest(); - const alpha = state.workspace?.workspaces?.find((workspace) => - workspace.cwd === path.join(root, "alpha") - ); - const delegation = state.workspace?.delegations[0]; - expect(delegation).toBeDefined(); - expect(alpha?.delegationIds).toEqual([delegation!.id]); - expect(delegation).toEqual( - expect.objectContaining({ - workspaceKey: alpha?.key, - discordWorkspaceThreadId: alpha?.discordThreadId, - }), - ); - } finally { - await bridge.stop(); - await rm(root, { recursive: true, force: true }); - } - }); - - test("workspace workbench surfaces hook-observed non-workspace threads", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "discord-observed-")); - const hookSpoolDir = await testHookSpoolDir(); - await mkdir(path.join(root, "alpha", "project"), { recursive: true }); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - cwd: root, - workspace: { - homeChannelId: "home-channel", - workspaceForumChannelId: "workspace-forum", - taskThreadsChannelId: "task-channel", - }, - hookSpoolDir, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - expect(transport.createdForumPosts.map((post) => post.name)).toEqual([ - "alpha", - ]); - await emitHookEvent(hookSpoolDir, { - eventName: "UserPromptSubmit", - sessionId: "codex-observed", - turnId: "turn-observed", - cwd: path.join(root, "alpha", "project"), - prompt: "Inspect observed runtime activity.", - }); - await waitFor(() => - bridge.stateForTest().workspace?.observedThreads?.[0]?.status === "active" - ); - await emitHookEvent(hookSpoolDir, { - eventName: "PermissionRequest", - sessionId: "codex-observed", - turnId: "turn-observed", - cwd: path.join(root, "alpha", "project"), - toolName: "Bash", - toolInput: { description: "Needs network" }, - }); - await waitFor(() => - bridge.stateForTest().workspace?.observedThreads?.[0]?.status === "waiting" - ); - expect(bridge.stateForTest().workspace?.observedThreads?.[0]).toEqual( - expect.objectContaining({ - threadId: "codex-observed", - title: "Inspect observed runtime activity.", - cwd: path.join(root, "alpha", "project"), - promptPreview: "Inspect observed runtime activity.", - permissionDescription: "Needs network", - }), - ); - await waitFor(() => transport.updatedMessages.some((message) => - message.channelId === "forum-post-1" && - message.text.includes( - "1. `not opened` Inspect observed runtime activity. (waiting: Needs network)", - ) - )); - - const replies: string[] = []; - transport.emit({ - kind: "threads", - channelId: "forum-post-1", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-14T12:00:30.000Z", - reply: async (text) => { - replies.push(text); - }, - replyPicker: transport.threadsReplyPicker(), - }); - await waitFor(() => transport.ephemeralPickers.length === 1); - expect(replies).toEqual([]); - expect(transport.messages.some((message) => - message.channelId === "forum-post-1" && - message.text.includes("Inspect observed runtime activity") - )).toBe(true); - const picker = transport.ephemeralPickers[0]; - expect(picker?.text).toContain( - "1️⃣ `not opened` Inspect observed runtime activity. (waiting: Needs network)", - ); - - transport.emitThreadPicker({ - pickerId: picker?.pickerId ?? "", - optionId: "0", - }); - await waitFor(() => transport.createdThreads.length === 1); - expect(client.resumeThreadCalls.some((call) => - call.threadId === "codex-observed" && - call.cwd === path.join(root, "alpha", "project") - )).toBe(true); - expect(transport.createdThreads[0]).toEqual({ - channelId: "task-channel", - name: "alpha: Inspect observed runtime activity.", - sourceMessageId: undefined, - }); - } finally { - await bridge.stop(); - await rm(hookSpoolDir, { recursive: true, force: true }); - await rm(root, { recursive: true, force: true }); - } - }); - - test("workspace hook drain continues when workspace dashboard updates fail", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "discord-observed-fail-")); - const hookSpoolDir = await testHookSpoolDir(); - await mkdir(path.join(root, "alpha", "project"), { recursive: true }); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - cwd: root, - workspace: { - homeChannelId: "home-channel", - workspaceForumChannelId: "workspace-forum", - taskThreadsChannelId: "task-channel", - }, - hookSpoolDir, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - transport.failUpdateMessages = true; - await emitHookEvent(hookSpoolDir, { - eventName: "UserPromptSubmit", - sessionId: "codex-observed-fail", - turnId: "turn-observed-fail", - cwd: path.join(root, "alpha", "project"), - prompt: "Keep draining hooks.", - }); - await waitFor(() => - bridge.stateForTest().workspace?.observedThreads?.some((thread) => - thread.threadId === "codex-observed-fail" && - thread.status === "active" - ) ?? false - ); - await waitFor(async () => - (await readdir(path.join(hookSpoolDir, "pending"))).length === 0 - ); - } finally { - await bridge.stop(); - await rm(hookSpoolDir, { recursive: true, force: true }); - await rm(root, { recursive: true, force: true }); - } - }); - - test("workspace workbench resumes persisted task thread sessions after restart", async () => { - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "discord-workbench-root-")); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const existingWorkspaceKey = testWorkspaceKey("/workspace/codex-flows"); - const store = new MemoryStateStore({ - ...emptyState(), - workspace: { - homeChannelId: "home-channel", - mainThreadId: "codex-main", - toolsVersion: 1, - delegations: [ - { - id: "delegation-existing", - codexThreadId: "codex-delegated", - title: "Existing task", - status: "idle", - cwd: "/workspace/codex-flows", - workspaceKey: existingWorkspaceKey, - discordTaskThreadId: "task-thread-existing", - discordWorkspaceThreadId: "workspace-post-existing", - createdAt: "2026-05-14T11:00:00.000Z", - updatedAt: "2026-05-14T11:00:00.000Z", - }, - ], - workspaces: [ - { - key: existingWorkspaceKey, - cwd: "/workspace/codex-flows", - title: "codex-flows", - discordThreadId: "workspace-post-existing", - statusMessageId: "workspace-status-existing", - delegationIds: ["delegation-existing"], - createdAt: "2026-05-14T11:00:00.000Z", - updatedAt: "2026-05-14T11:00:00.000Z", - }, - ], - }, - sessions: [ - { - discordThreadId: "home-channel", - parentChannelId: "home-channel", - codexThreadId: "codex-main", - title: "Codex Workspace", - createdAt: "2026-05-14T11:00:00.000Z", - cwd: "/workspace", - mode: "workspace", - }, - { - discordThreadId: "task-thread-existing", - parentChannelId: "task-channel", - codexThreadId: "codex-delegated", - title: "Existing task", - createdAt: "2026-05-14T11:00:00.000Z", - cwd: "/workspace/codex-flows", - mode: "delegated", - }, - ], - queue: [], - activeTurns: [], - processedMessageIds: [], - deliveries: [], - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig({ - cwd: workspaceRoot, - workspace: { - homeChannelId: "home-channel", - workspaceForumChannelId: "workspace-forum", - taskThreadsChannelId: "task-channel", - }, - allowedChannelIds: new Set(["home-channel"]), - }), - }); - - try { - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 2); - expect(transport.createdForumPosts).toEqual([]); - expect(transport.createdThreads).toEqual([]); - - transport.emit({ - kind: "message", - channelId: "task-thread-existing", - messageId: "message-existing-task", - author: { id: "user-1", name: "Peezy", isBot: false }, - content: "Continue the restarted delegated task.", - createdAt: "2026-05-14T12:00:00.000Z", - }); - await waitFor(() => client.startTurnCalls.length === 1); - expect(client.startTurnCalls[0]).toEqual( - expect.objectContaining({ - threadId: "codex-delegated", - cwd: "/workspace/codex-flows", - }), - ); - } finally { - await bridge.stop(); - await rm(workspaceRoot, { recursive: true, force: true }); - } - }); - - test("workspace rejects dynamic tool calls outside the main operator thread", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - }), - }); - - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - client.emitRequest({ - id: "tool-1", - method: "item/tool/call", - params: { - threadId: "codex-thread-elsewhere", - namespace: "codex_workspace", - tool: "list_delegations", - arguments: {}, - }, - }); - - await waitFor(() => client.responseErrors.length === 1); - expect(client.responseErrors[0]).toEqual( - expect.objectContaining({ - id: "tool-1", - code: -32601, - message: "Unknown dynamic tool request", - }), - ); - expect(client.responses).toEqual([]); - await bridge.stop(); - }); - - test("workspace records group delegation results and wakes after the group finishes", async () => { - const hookSpoolDir = await testHookSpoolDir(); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - hookSpoolDir, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - for (const [index, title] of ["Workspace A", "Workspace B"].entries()) { - client.emitRequest({ - id: `tool-${index}`, - method: "item/tool/call", - params: { - threadId: "codex-thread-1", - namespace: "codex_workspace", - tool: "start_delegation", - arguments: { - cwd: `/workspace/${index}`, - title, - prompt: `Inspect ${title}.`, - groupId: "fanout", - }, - }, - }); - await waitFor(() => client.responses.length === index + 1); - } - - await emitStopHook(hookSpoolDir, { - sessionId: "codex-thread-2", - turnId: "turn-1", - lastAssistantMessage: "Result A.", - }); - await waitFor(() => client.injectThreadItemsCalls.length === 1); - expect(client.startTurnCalls).toHaveLength(2); - expect(client.readThreadCalls).toEqual([]); - expect(transport.messages.some((message) => - message.channelId === "home-channel" && - message.text.includes("Result A.") - )).toBe(true); - - await emitStopHook(hookSpoolDir, { - sessionId: "codex-thread-3", - turnId: "turn-2", - lastAssistantMessage: "Result B.", - }); - await waitFor(() => client.startTurnCalls.length === 3); - expect(client.injectThreadItemsCalls).toHaveLength(2); - expect(client.startTurnCalls[2]).toEqual( - expect.objectContaining({ - threadId: "codex-thread-1", - }), - ); - expect(inputText(client.startTurnCalls[2]?.input[0])).toContain( - "Delegation group fanout completed.", - ); - expect(bridge.stateForTest().workspace?.pendingWakes?.[0]).toEqual( - expect.objectContaining({ - kind: "group", - groupId: "fanout", - startedAt: "2026-05-14T12:00:00.000Z", - }), - ); - await sleep(30); - expect(client.startTurnCalls).toHaveLength(3); - expect(bridge.stateForTest().workspace?.pendingWakes).toHaveLength(1); - } finally { - await bridge.stop(); - await rm(hookSpoolDir, { recursive: true, force: true }); - } - }); - - test("workspace detached delegations complete without injecting or waking the main thread", async () => { - const hookSpoolDir = await testHookSpoolDir(); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - hookSpoolDir, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - client.emitRequest({ - id: "tool-1", - method: "item/tool/call", - params: { - threadId: "codex-thread-1", - namespace: "codex_workspace", - tool: "start_delegation", - arguments: { - cwd: "/workspace/detached", - title: "Detached workspace", - prompt: "Prepare this for a human.", - returnMode: "detached", - }, - }, - }); - - await waitFor(() => client.responses.length === 1); - await emitStopHook(hookSpoolDir, { - sessionId: "codex-thread-2", - turnId: "turn-1", - lastAssistantMessage: "Detached result.", - }); - await waitFor(() => - bridge.stateForTest().workspace?.delegations[0]?.status === "complete" - ); - expect(client.injectThreadItemsCalls).toEqual([]); - expect(client.startTurnCalls).toHaveLength(1); - expect(client.readThreadCalls).toEqual([]); - expect(transport.messages.some((message) => - message.text.includes("Detached result.") - )).toBe(false); - } finally { - await bridge.stop(); - await rm(hookSpoolDir, { recursive: true, force: true }); - } - }); - - test("workspace queues delegation wake while the main operator thread is busy", async () => { - const hookSpoolDir = await testHookSpoolDir(); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - hookSpoolDir, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - transport.emit({ - kind: "message", - channelId: "home-channel", - messageId: "home-message-1", - author: { id: "user-1", name: "Peezy", isBot: false }, - content: "work on a long-running main task", - createdAt: "2026-05-14T12:00:00.000Z", - }); - await waitFor(() => bridge.stateForTest().activeTurns.length === 1); - - client.emitRequest({ - id: "tool-1", - method: "item/tool/call", - params: { - threadId: "codex-thread-1", - namespace: "codex_workspace", - tool: "start_delegation", - arguments: { - cwd: "/workspace/side", - title: "Side task", - prompt: "Finish this side task.", - }, - }, - }); - - await waitFor(() => client.responses.length === 1); - await emitStopHook(hookSpoolDir, { - sessionId: "codex-thread-2", - turnId: "turn-2", - lastAssistantMessage: "Side task result.", - }); - await waitFor(() => client.injectThreadItemsCalls.length === 1); - expect(client.startTurnCalls).toHaveLength(2); - expect(bridge.stateForTest().workspace?.pendingWakes?.[0]).toEqual( - expect.objectContaining({ - kind: "delegation", - }), - ); - expect(bridge.stateForTest().workspace?.pendingWakes?.[0]).not.toHaveProperty( - "startedAt", - ); - await emitStopHook(hookSpoolDir, { - sessionId: "codex-thread-1", - turnId: "turn-1", - lastAssistantMessage: "Main task paused.", - }); - await waitFor(() => client.startTurnCalls.length === 3); - expect(inputText(client.startTurnCalls[2]?.input[0])).toContain( - "Delegation Side task completed.", - ); - expect(bridge.stateForTest().workspace?.pendingWakes?.[0]).toEqual( - expect.objectContaining({ - startedAt: "2026-05-14T12:00:00.000Z", - }), - ); - } finally { - await bridge.stop(); - await rm(hookSpoolDir, { recursive: true, force: true }); - } - }); - - test("workspace record-only delegations inject and mirror without waking", async () => { - const hookSpoolDir = await testHookSpoolDir(); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - hookSpoolDir, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - client.emitRequest({ - id: "tool-1", - method: "item/tool/call", - params: { - threadId: "codex-thread-1", - namespace: "codex_workspace", - tool: "start_delegation", - arguments: { - cwd: "/workspace/record", - title: "Record only task", - prompt: "Record this result.", - returnMode: "record_only", - }, - }, - }); - - await waitFor(() => client.responses.length === 1); - await emitStopHook(hookSpoolDir, { - sessionId: "codex-thread-2", - turnId: "turn-1", - lastAssistantMessage: "Record-only result.", - }); - await waitFor(() => client.injectThreadItemsCalls.length === 1); - expect(client.startTurnCalls).toHaveLength(1); - expect(bridge.stateForTest().workspace?.pendingWakes ?? []).toEqual([]); - expect(transport.messages.some((message) => - message.text.includes("Record-only result.") - )).toBe(true); - } finally { - await bridge.stop(); - await rm(hookSpoolDir, { recursive: true, force: true }); - } - }); - - test("workspace drains queued stop hook events on startup", async () => { - const hookSpoolDir = await testHookSpoolDir(); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore({ - ...emptyState(), - workspace: { - homeChannelId: "home-channel", - mainThreadId: "codex-thread-1", - toolsVersion: 1, - delegations: [ - { - id: "delegation-queued", - codexThreadId: "codex-thread-2", - title: "Queued event task", - status: "active", - cwd: "/workspace/queued", - returnMode: "record_only", - createdAt: "2026-05-14T11:59:00.000Z", - updatedAt: "2026-05-14T11:59:00.000Z", - }, - ], - pendingWakes: [], - processedStopHookEventIds: [], - }, - sessions: [ - { - discordThreadId: "home-channel", - parentChannelId: "home-channel", - codexThreadId: "codex-thread-1", - title: "Codex Workspace", - createdAt: "2026-05-14T11:59:00.000Z", - cwd: "/workspace", - mode: "workspace", - }, - ], - }); - await emitStopHook(hookSpoolDir, { - sessionId: "codex-thread-2", - turnId: "turn-queued", - lastAssistantMessage: "Queued result.", - cwd: "/workspace/queued", - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - hookSpoolDir, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - await waitFor(() => client.injectThreadItemsCalls.length === 1); - expect(client.readThreadCalls).toEqual([]); - expect(bridge.stateForTest().workspace?.delegations[0]).toEqual( - expect.objectContaining({ - status: "complete", - lastTurnId: "turn-queued", - lastFinal: "Queued result.", - injectedAt: "2026-05-14T12:00:00.000Z", - }), - ); - expect(transport.messages.some((message) => - message.text.includes("Queued result.") - )).toBe(true); - } finally { - await bridge.stop(); - await rm(hookSpoolDir, { recursive: true, force: true }); - } - }); - - test("workspace stop hook events are idempotent", async () => { - const hookSpoolDir = await testHookSpoolDir(); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - hookSpoolDir, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - client.emitRequest({ - id: "tool-1", - method: "item/tool/call", - params: { - threadId: "codex-thread-1", - namespace: "codex_workspace", - tool: "start_delegation", - arguments: { - cwd: "/workspace/idempotent", - title: "Idempotent task", - prompt: "Return once.", - returnMode: "record_only", - }, - }, - }); - await waitFor(() => client.responses.length === 1); - await emitStopHook(hookSpoolDir, { - sessionId: "codex-thread-2", - turnId: "turn-1", - lastAssistantMessage: "Exactly once.", - }); - await waitFor(() => client.injectThreadItemsCalls.length === 1); - await emitStopHook(hookSpoolDir, { - sessionId: "codex-thread-2", - turnId: "turn-1", - lastAssistantMessage: "Duplicate with changed text.", - }); - await sleep(200); - expect(client.injectThreadItemsCalls).toHaveLength(1); - expect(transport.messages.filter((message) => - message.text.includes("Exactly once.") - )).toHaveLength(1); - expect( - bridge.stateForTest().workspace?.processedStopHookEventIds, - ).toHaveLength(1); - } finally { - await bridge.stop(); - await rm(hookSpoolDir, { recursive: true, force: true }); - } - }); - - test("workspace manually flushes completed manual delegation results", async () => { - const hookSpoolDir = await testHookSpoolDir(); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - hookSpoolDir, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - client.emitRequest({ - id: "tool-1", - method: "item/tool/call", - params: { - threadId: "codex-thread-1", - namespace: "codex_workspace", - tool: "start_delegation", - arguments: { - cwd: "/workspace/manual", - title: "Manual task", - prompt: "Finish manually.", - returnMode: "manual", - }, - }, - }); - - await waitFor(() => client.responses.length === 1); - await emitStopHook(hookSpoolDir, { - sessionId: "codex-thread-2", - turnId: "turn-1", - lastAssistantMessage: "Manual result.", - }); - await waitFor(() => - bridge.stateForTest().workspace?.delegations[0]?.status === "complete" - ); - expect(client.injectThreadItemsCalls).toEqual([]); - client.emitRequest({ - id: "tool-2", - method: "item/tool/call", - params: { - threadId: "codex-thread-1", - namespace: "codex_workspace", - tool: "flush_delegation_results", - arguments: { - delegationId: bridge.stateForTest().workspace?.delegations[0]?.id, - wake: "false", - }, - }, - }); - - await waitFor(() => client.injectThreadItemsCalls.length === 1); - expect(client.startTurnCalls).toHaveLength(1); - await waitFor(() => - transport.messages.some((message) => - message.text.includes("Manual result.") - ) - ); - } finally { - await bridge.stop(); - await rm(hookSpoolDir, { recursive: true, force: true }); - } - }); - - test("answers workspace status command without starting a turn", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const replies: string[] = []; - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - }), - }); - - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - transport.emit({ - kind: "status", - channelId: "home-channel", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-14T00:00:00.000Z", - reply: async (text) => { - replies.push(text); - }, - }); - - await waitFor(() => replies.length === 1); - expect(replies[0]).toContain("**Codex Workspace**"); - expect(client.startTurnCalls).toHaveLength(0); - await bridge.stop(); - }); - - test("status lists active Codex threads and opens unlinked threads", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "discord-status-")); - await mkdir(path.join(root, "alpha", "project"), { recursive: true }); - await mkdir(path.join(root, "beta", "project"), { recursive: true }); - const client = new FakeCodexClient(); - client.threads = [ - testThread({ - id: "codex-active-linked", - cwd: path.join(root, "alpha", "project"), - name: "Linked active", - status: { type: "active" } as v2.ThreadStatus, - updatedAt: 30, - }), - testThread({ - id: "codex-active-missing", - cwd: path.join(root, "beta", "project"), - name: "Missing active", - status: { type: "active" } as v2.ThreadStatus, - updatedAt: 40, - }), - testThread({ - id: "codex-idle", - cwd: path.join(root, "beta", "project"), - name: "Idle thread", - status: { type: "idle" } as v2.ThreadStatus, - updatedAt: 50, - }), - ]; - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore({ - ...emptyState(), - sessions: [ - { - discordThreadId: "task-linked", - parentChannelId: "task-channel", - codexThreadId: "codex-active-linked", - title: "Linked active", - createdAt: "2026-05-14T11:00:00.000Z", - cwd: path.join(root, "alpha", "project"), - mode: "workspace", - }, - ], - }), - config: testConfig({ - cwd: root, - workspace: { - homeChannelId: "home-channel", - workspaceForumChannelId: "workspace-forum", - taskThreadsChannelId: "task-channel", - }, - }), - }); - - try { - await bridge.start(); - const replies: string[] = []; - transport.emit({ - kind: "status", - channelId: "home-channel", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-14T12:00:00.000Z", - reply: async (text) => { - replies.push(text); - }, - replyPicker: transport.threadsReplyPicker(), - }); - - await waitFor(() => transport.ephemeralPickers.length === 1); - expect(replies).toEqual([]); - const picker = transport.ephemeralPickers[0]; - expect(picker?.text).toContain("**Active Codex Threads**"); - expect(picker?.text).toContain("<#task-linked> Linked active (active)"); - expect(picker?.text).toContain("1️⃣ `not opened` Missing active (active)"); - expect(picker?.text).not.toContain("Idle thread"); - expect(picker?.options).toEqual([{ id: "0", label: "1" }]); - - transport.emitThreadPicker({ - pickerId: picker?.pickerId ?? "", - optionId: "0", - }); - await waitFor(() => transport.createdThreads.length === 1); - expect(client.resumeThreadCalls.some((call) => - call.threadId === "codex-active-missing" - )).toBe(true); - expect(transport.createdThreads[0]).toEqual({ - channelId: "task-channel", - name: "beta: Missing active", - sourceMessageId: undefined, - }); - expect(transport.ephemeralUpdates.some((update) => - update.pickerId === picker?.pickerId && - update.text === "Opened Missing active: <#discord-thread-1>" - )).toBe(true); - } finally { - await bridge.stop(); - await rm(root, { recursive: true, force: true }); - } - }); - - test("multi-guild workspace surfaces scope workspaces, status, hooks, and home delivery", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "discord-surfaces-")); - const hookSpoolDir = await testHookSpoolDir(); - const alphaCwd = path.join(root, "alpha", "project"); - const cryptoWorkspace = path.join(root, "crypto-workspace"); - const cryptoCwd = path.join(cryptoWorkspace, "project"); - await mkdir(alphaCwd, { recursive: true }); - await mkdir(cryptoCwd, { recursive: true }); - const client = new FakeCodexClient(); - client.threads = [ - testThread({ - id: "codex-alpha-active", - cwd: alphaCwd, - name: "Alpha active", - status: { type: "active" } as v2.ThreadStatus, - updatedAt: 20, - }), - testThread({ - id: "codex-crypto-active", - cwd: cryptoCwd, - name: "Crypto active", - status: { type: "active" } as v2.ThreadStatus, - updatedAt: 30, - }), - ]; - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - cwd: root, - workspace: { - homeChannelId: "home-default", - workspaceForumChannelId: "forum-default", - taskThreadsChannelId: "tasks-default", - surfaces: [ - { - key: "default", - homeChannelId: "home-default", - workspaceForumChannelId: "forum-default", - taskThreadsChannelId: "tasks-default", - }, - { - key: "crypto", - homeChannelId: "home-crypto", - workspaceForumChannelId: "forum-crypto", - taskThreadsChannelId: "tasks-crypto", - workspaceCwds: [cryptoWorkspace], - }, - ], - }, - hookSpoolDir, - }), - now: () => new Date("2026-05-14T12:00:00.000Z"), - }); - - try { - await bridge.start(); - expect(transport.createdForumPosts).toEqual([ - expect.objectContaining({ - channelId: "forum-default", - name: "alpha", - threadId: "forum-post-1", - }), - expect.objectContaining({ - channelId: "forum-crypto", - name: "crypto-workspace", - threadId: "forum-post-2", - }), - ]); - expect(bridge.stateForTest().workspace?.workspaces).toEqual([ - expect.objectContaining({ - cwd: path.join(root, "alpha"), - surfaceKey: "default", - }), - expect.objectContaining({ - cwd: cryptoWorkspace, - surfaceKey: "crypto", - }), - ]); - expect(transport.registeredCommands).toEqual([ - { - channelIds: [ - "parent-channel", - "home-default", - "forum-default", - "tasks-default", - "home-crypto", - "forum-crypto", - "tasks-crypto", - ], - }, - ]); - - transport.emit({ - kind: "status", - channelId: "home-crypto", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-14T12:00:30.000Z", - reply: async () => {}, - replyPicker: transport.threadsReplyPicker(), - }); - await waitFor(() => transport.ephemeralPickers.length === 1); - const statusPicker = transport.ephemeralPickers[0]; - expect(statusPicker?.text).toContain("Surface: `crypto`"); - expect(statusPicker?.text).toContain("1️⃣ `not opened` Crypto active (active)"); - expect(statusPicker?.text).not.toContain("Alpha active"); - - transport.emitThreadPicker({ - pickerId: statusPicker?.pickerId ?? "", - optionId: "0", - }); - await waitFor(() => transport.createdThreads.length === 1); - expect(transport.createdThreads[0]).toEqual({ - channelId: "tasks-crypto", - name: "crypto-workspace: Crypto active", - sourceMessageId: undefined, - }); - expect(bridge.stateForTest().sessions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - discordThreadId: "discord-thread-1", - parentChannelId: "tasks-crypto", - codexThreadId: "codex-crypto-active", - surfaceKey: "crypto", - }), - ]), - ); - - await emitHookEvent(hookSpoolDir, { - eventName: "UserPromptSubmit", - sessionId: "codex-crypto-observed", - turnId: "turn-crypto-observed", - cwd: cryptoCwd, - prompt: "Watch the crypto workspace.", - }); - await waitFor(() => - bridge.stateForTest().workspace?.observedThreads?.some((thread) => - thread.threadId === "codex-crypto-observed" && - thread.surfaceKey === "crypto" && - thread.status === "active" - ) ?? false - ); - expect(transport.updatedMessages.some((message) => - message.channelId === "forum-post-2" && - message.text.includes("Watch the crypto workspace.") - )).toBe(true); - expect(transport.updatedMessages.some((message) => - message.channelId === "forum-post-1" && - message.text.includes("Watch the crypto workspace.") - )).toBe(false); - - transport.emit({ - kind: "threads", - channelId: "forum-post-2", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-14T12:00:45.000Z", - reply: async () => {}, - replyPicker: transport.threadsReplyPicker(), - }); - await waitFor(() => transport.ephemeralPickers.length === 2); - expect(transport.ephemeralPickers[1]?.text).toContain("Crypto active"); - expect(transport.ephemeralPickers[1]?.text).toContain( - "Watch the crypto workspace.", - ); - expect(transport.ephemeralPickers[1]?.text).not.toContain("Alpha active"); - - client.threadGoals.set("codex-crypto-active", { - threadId: "codex-crypto-active", - objective: "Manage crypto workspace goals", - status: "active", - tokenBudget: null, - tokensUsed: 0, - timeUsedSeconds: 0, - createdAt: 1, - updatedAt: 1, - }); - transport.emit({ - kind: "goals", - channelId: "forum-post-2", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-14T12:00:50.000Z", - reply: async () => {}, - replyPicker: transport.threadsReplyPicker(), - }); - await waitFor(() => transport.ephemeralPickers.length === 3); - expect(transport.ephemeralPickers[2]?.text).toContain( - "Manage crypto workspace goals", - ); - expect(transport.ephemeralPickers[2]?.text).not.toContain("Alpha active"); - - transport.emit({ - kind: "message", - channelId: "home-crypto", - messageId: "home-crypto-message", - author: { id: "user-1", name: "Peezy", isBot: false }, - content: "hello from crypto guild", - createdAt: "2026-05-14T12:01:00.000Z", - }); - await waitFor(() => client.startTurnCalls.length === 1); - expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( - "Surface: crypto", - ); - expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( - "Home channel: home-crypto", - ); - - client.emitNotification({ - method: "item/completed", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - item: { - id: "message-crypto-final", - type: "agentMessage", - text: "Crypto workspace answer.", - phase: "final_answer", - memoryCitation: null, - }, - }, - }); - client.emitNotification({ - method: "turn/completed", - params: { - threadId: "codex-thread-1", - turn: { - id: "turn-1", - status: "completed", - items: [], - }, - }, - }); - await waitFor(() => - transport.messages.some((message) => - message.channelId === "home-crypto" && - message.text === "Crypto workspace answer." - ) - ); - expect(transport.messages.some((message) => - message.channelId === "home-default" && - message.text === "Crypto workspace answer." - )).toBe(false); - } finally { - await bridge.stop(); - await rm(hookSpoolDir, { recursive: true, force: true }); - await rm(root, { recursive: true, force: true }); - } - }); - - test("goals command manages thread goals from workspace forum posts", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "discord-goals-")); - await mkdir(path.join(root, "alpha", "project"), { recursive: true }); - const client = new FakeCodexClient(); - client.threads = [ - testThread({ - id: "codex-goal", - cwd: path.join(root, "alpha", "project"), - name: "Goal thread", - updatedAt: 30, - }), - ]; - client.threadGoals.set("codex-goal", { - threadId: "codex-goal", - objective: "Ship goal management", - status: "active", - tokenBudget: null, - tokensUsed: 42, - timeUsedSeconds: 9, - createdAt: 1, - updatedAt: 2, - }); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - cwd: root, - workspace: { - homeChannelId: "home-channel", - workspaceForumChannelId: "workspace-forum", - taskThreadsChannelId: "task-channel", - }, - }), - }); - - try { - await bridge.start(); - expect(transport.createdForumPosts.map((post) => post.name)).toEqual([ - "alpha", - ]); - - const replies: string[] = []; - transport.emit({ - kind: "goals", - channelId: "task-channel", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-15T00:00:00.000Z", - reply: async (text) => { - replies.push(text); - }, - replyPicker: transport.threadsReplyPicker(), - }); - await waitFor(() => replies.length === 1); - expect(replies[0]).toBe( - "Run `/goals` in a workspace forum post or opened Codex thread.", - ); - - transport.emit({ - kind: "goals", - channelId: "forum-post-1", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-15T00:00:01.000Z", - reply: async (text) => { - replies.push(text); - }, - replyPicker: transport.threadsReplyPicker(), - }); - await waitFor(() => transport.ephemeralPickers.length === 1); - const picker = transport.ephemeralPickers[0]; - expect(picker?.text).toContain("**Goals: alpha**"); - expect(picker?.text).toContain("1️⃣ `not opened` Goal thread - `active` Ship goal management"); - expect(picker?.options).toEqual([{ id: "0", label: "1" }]); - - transport.emitThreadPicker({ - pickerId: picker?.pickerId ?? "", - optionId: "0", - }); - await waitFor(() => transport.ephemeralPickers.length === 2); - const actionPicker = transport.ephemeralPickers[1]; - expect(actionPicker?.text).toContain("**Goal: Goal thread**"); - expect(actionPicker?.text).toContain("Goal: `active` Ship goal management"); - expect(actionPicker?.options).toEqual([ - { id: "open", label: "Open" }, - { id: "status:paused", label: "Pause" }, - { id: "status:complete", label: "Complete" }, - { id: "clear", label: "Clear" }, - ]); - - transport.emitThreadPicker({ - pickerId: actionPicker?.pickerId ?? "", - optionId: "status:complete", - }); - await waitFor(() => client.setThreadGoalCalls.length === 1); - expect(client.setThreadGoalCalls[0]).toEqual({ - threadId: "codex-goal", - status: "complete", - }); - await waitFor(() => transport.ephemeralPickers.length === 3); - expect(transport.ephemeralPickers[2]?.text).toContain( - "Set goal status to complete.", - ); - expect(transport.ephemeralPickers[2]?.text).toContain( - "Goal: `complete` Ship goal management", - ); - } finally { - await bridge.stop(); - await rm(root, { recursive: true, force: true }); - } - }); - - test("goals command manages the current Discord thread goal", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "discord-thread-goals-")); - const cwd = path.join(root, "alpha", "project"); - await mkdir(cwd, { recursive: true }); - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore({ - ...emptyState(), - sessions: [ - { - discordThreadId: "task-goal", - parentChannelId: "task-channel", - codexThreadId: "codex-thread-goal", - title: "Goal task", - createdAt: "2026-05-14T11:00:00.000Z", - cwd, - mode: "workspace", - }, - ], - }), - config: testConfig({ - cwd: root, - workspace: { - homeChannelId: "home-channel", - workspaceForumChannelId: "workspace-forum", - taskThreadsChannelId: "task-channel", - }, - }), - }); - - try { - await bridge.start(); - - const replies: string[] = []; - transport.emit({ - kind: "goals", - channelId: "task-goal", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-15T00:00:00.000Z", - objective: "Improve delegation CRUD", - goalStatus: "active", - tokenBudget: 1234, - reply: async (text) => { - replies.push(text); - }, - replyPicker: transport.threadsReplyPicker(), - }); - await waitFor(() => client.setThreadGoalCalls.length === 1); - expect(client.setThreadGoalCalls[0]).toEqual({ - threadId: "codex-thread-goal", - objective: "Improve delegation CRUD", - status: "active", - tokenBudget: 1234, - }); - await waitFor(() => transport.ephemeralPickers.length === 1); - expect(transport.ephemeralPickers[0]?.text).toContain("Saved goal."); - expect(transport.ephemeralPickers[0]?.text).toContain( - "Goal: `active` Improve delegation CRUD", - ); - - transport.emit({ - kind: "goals", - channelId: "task-goal", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-15T00:00:01.000Z", - reply: async (text) => { - replies.push(text); - }, - replyPicker: transport.threadsReplyPicker(), - }); - await waitFor(() => transport.ephemeralPickers.length === 2); - const picker = transport.ephemeralPickers[1]; - expect(picker?.text).toContain("**Goal: Goal task**"); - expect(picker?.text).toContain("Thread: <#task-goal> `codex-thread-goal`"); - expect(picker?.options).toEqual([ - { id: "status:paused", label: "Pause" }, - { id: "status:complete", label: "Complete" }, - { id: "clear", label: "Clear" }, - ]); - - transport.emitThreadPicker({ - pickerId: picker?.pickerId ?? "", - optionId: "status:complete", - }); - await waitFor(() => client.setThreadGoalCalls.length === 2); - expect(client.setThreadGoalCalls[1]).toEqual({ - threadId: "codex-thread-goal", - status: "complete", - }); - await waitFor(() => transport.ephemeralPickers.length === 3); - expect(transport.ephemeralPickers[2]?.text).toContain( - "Set goal status to complete.", - ); - - transport.emit({ - kind: "goals", - channelId: "task-goal", - author: { id: "user-1", name: "Peezy", isBot: false }, - createdAt: "2026-05-15T00:00:02.000Z", - clear: true, - reply: async (text) => { - replies.push(text); - }, - replyPicker: transport.threadsReplyPicker(), - }); - await waitFor(() => client.clearThreadGoalCalls.length === 1); - expect(client.clearThreadGoalCalls[0]).toEqual({ - threadId: "codex-thread-goal", - }); - await waitFor(() => replies.includes("Cleared goal for Goal task.")); - } finally { - await bridge.stop(); - await rm(root, { recursive: true, force: true }); - } - }); - - test("resumes a configured workspace main thread without creating Discord threads", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - workspace: { - homeChannelId: "home-channel", - mainThreadId: "codex-main-thread", - }, - }), - }); - - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - - expect(client.startThreadCalls).toHaveLength(0); - expect(client.resumeThreadCalls[0]).toEqual( - expect.objectContaining({ threadId: "codex-main-thread" }), - ); - expect(transport.createdThreads).toEqual([]); - expect(bridge.stateForTest().sessions[0]).toEqual( - expect.objectContaining({ - discordThreadId: "home-channel", - codexThreadId: "codex-main-thread", - cwd: "/workspace", - mode: "operator", - }), - ); - await bridge.stop(); - }); - - test("replaces stale persisted workspace sessions when no main thread is configured", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore({ - ...emptyState(), - workspace: { - homeChannelId: "home-channel", - mainThreadId: "old-codex-thread", - createdAt: "2026-05-13T00:00:00.000Z", - delegations: [], - }, - sessions: [ - { - discordThreadId: "home-channel", - parentChannelId: "home-channel", - codexThreadId: "old-codex-thread", - title: "Codex Workspace", - createdAt: "2026-05-13T00:00:00.000Z", - cwd: "/workspace", - mode: "workspace", - }, - ], - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - }), - }); - - await bridge.start(); - await waitFor(() => bridge.stateForTest().workspace?.mainThreadId === "codex-thread-1"); - - expect(client.resumeThreadCalls).toEqual([]); - expect(client.startThreadCalls).toHaveLength(1); - expect(client.startThreadCalls[0]?.dynamicTools).toEqual( - expect.arrayContaining([ - expect.objectContaining({ namespace: "codex_workspace" }), - ]), - ); - expect(bridge.stateForTest().sessions.filter((session) => - session.mode === "operator" - )).toEqual([ - expect.objectContaining({ - codexThreadId: "codex-thread-1", - }), - ]); - expect(bridge.stateForTest().workspace).toEqual( - expect.objectContaining({ - mainThreadId: "codex-thread-1", - toolsVersion: 1, - }), - ); - await bridge.stop(); - }); - - test("recreates a tool-enabled workspace session when resume reports thread not found", async () => { - const client = new FakeCodexClient(); - client.failedResumeThreadIds.add("missing-codex-thread"); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore({ - ...emptyState(), - workspace: { - homeChannelId: "home-channel", - mainThreadId: "missing-codex-thread", - createdAt: "2026-05-13T00:00:00.000Z", - toolsVersion: 1, - delegations: [], - }, - sessions: [ - { - discordThreadId: "home-channel", - parentChannelId: "home-channel", - codexThreadId: "missing-codex-thread", - title: "Codex Workspace", - createdAt: "2026-05-13T00:00:00.000Z", - cwd: "/workspace", - mode: "workspace", - }, - ], - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - }), - }); - - await bridge.start(); - await waitFor(() => bridge.stateForTest().workspace?.mainThreadId === "codex-thread-1"); - - expect(client.resumeThreadCalls[0]).toEqual( - expect.objectContaining({ threadId: "missing-codex-thread" }), - ); - expect(client.startThreadCalls).toHaveLength(1); - expect(client.startThreadCalls[0]?.dynamicTools).toEqual( - expect.arrayContaining([ - expect.objectContaining({ namespace: "codex_workspace" }), - ]), - ); - expect(bridge.stateForTest().workspace).toEqual( - expect.objectContaining({ - mainThreadId: "codex-thread-1", - toolsVersion: 1, - }), - ); - expect(bridge.stateForTest().sessions.filter((session) => - session.mode === "operator" - )).toEqual([ - expect.objectContaining({ - codexThreadId: "codex-thread-1", - }), - ]); - await bridge.stop(); - }); - - test("routes bot mentions in the home channel to the workspace instead of creating threads", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ - workspace: { homeChannelId: "home-channel" }, - }), - }); - - await bridge.start(); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - transport.emit({ - kind: "threadStart", - channelId: "home-channel", - sourceMessageId: "mention-message-1", - author: { id: "user-1", name: "Peezy", isBot: false }, - prompt: "<@bot-id> in load-game check active work", - mentionedUserIds: ["bot-id"], - createdAt: "2026-05-14T00:00:00.000Z", - }); - - await waitFor(() => client.startTurnCalls.length === 1); - expect(transport.createdThreads).toEqual([]); - expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( - "in load-game check active work", - ); - await bridge.stop(); - }); - - test("starts a Discord thread from a mention and sends summaries only after chunks complete", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "threadStart", - sourceMessageId: "message-mention-1", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - title: "Investigate release", - prompt: "What changed in this release?", - createdAt: "2026-05-11T00:00:00.000Z", - }); - - await waitFor(() => client.startTurnCalls.length === 1); - expect(transport.createdThreads).toEqual([ - { - channelId: "parent-channel", - name: "Investigate release", - sourceMessageId: "message-mention-1", - }, - ]); - expect(client.startThreadCalls).toHaveLength(1); - expect(client.setThreadNameCalls[0]).toEqual({ - threadId: "codex-thread-1", - name: "[discord] Investigate release", - }); - expect(client.startTurnCalls[0]?.input[0]).toEqual( - expect.objectContaining({ - type: "text", - text: expect.stringContaining("What changed in this release?"), - }), - ); - - const messageCountAfterStart = transport.messages.length; - client.emitNotification({ - method: "item/reasoning/summaryPartAdded", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - itemId: "reasoning-1", - summaryIndex: 0, - }, - }); - client.emitNotification({ - method: "item/reasoning/summaryTextDelta", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - itemId: "reasoning-1", - summaryIndex: 0, - delta: "Checking changed files.", - }, - }); - client.emitNotification({ - method: "item/reasoning/summaryTextDelta", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - itemId: "reasoning-1", - summaryIndex: 0, - delta: " Reading test coverage.", - }, - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(transport.messages).toHaveLength(messageCountAfterStart); - expect( - transport.updatedMessages.some((message) => - message.text.includes("Checking changed files") - ), - ).toBe(false); - expect( - transport.messages.filter((message) => - message.text.includes("Checking changed files") - ), - ).toHaveLength(0); - client.emitNotification({ - method: "item/reasoning/summaryPartAdded", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - itemId: "reasoning-1", - summaryIndex: 1, - }, - }); - await waitFor(() => - transport.messages.some((message) => - message.text === "Checking changed files. Reading test coverage." - ) - ); - client.emitNotification({ - method: "item/reasoning/summaryTextDelta", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - itemId: "reasoning-1", - summaryIndex: 1, - delta: "Inspecting implementation boundaries.", - }, - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect( - transport.messages.some((message) => - message.text === "Inspecting implementation boundaries." - ), - ).toBe(false); - client.emitNotification({ - method: "item/completed", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - item: { - id: "reasoning-1", - type: "reasoning", - summary: [ - "Checking changed files. Reading test coverage.", - "Inspecting implementation boundaries.", - ], - }, - }, - }); - await waitFor(() => - transport.messages.some((message) => - message.text === "Inspecting implementation boundaries." - ) - ); - expect( - transport.updatedMessages.some((message) => - message.text === "Inspecting implementation boundaries." - ), - ).toBe(false); - await waitFor(() => transport.typingCount >= 2); - client.emitNotification({ - method: "item/agentMessage/delta", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - itemId: "message-1", - delta: "The release changed the Discord bridge.", - }, - }); - client.emitNotification({ - method: "item/completed", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - item: { - id: "message-1", - type: "agentMessage", - text: "The release changed the Discord bridge.", - phase: "final_answer", - memoryCitation: null, - }, - }, - }); - client.emitNotification({ - method: "turn/completed", - params: { - threadId: "codex-thread-1", - turn: { id: "turn-1" }, - }, - }); - - await waitFor(() => - transport.messages.some((message) => - message.text === "The release changed the Discord bridge." - ) - ); - expect(bridge.stateForTest().processedMessageIds).toContain( - "message-mention-1", - ); - expect(bridge.stateForTest().deliveries.map((delivery) => delivery.kind)).toEqual([ - "summary", - "summary", - "final", - ]); - await waitFor(() => transport.deletedMessages.length === 2); - expect(transport.deletedMessages.map((message) => message.text)).toEqual([ - "Checking changed files. Reading test coverage.", - "Inspecting implementation boundaries.", - ]); - expect( - transport.messages - .map((message) => message.text) - .filter((text) => - [ - "Checking changed files. Reading test coverage.", - "Inspecting implementation boundaries.", - "The release changed the Discord bridge.", - ].includes(text) - ), - ).toEqual([ - "The release changed the Discord bridge.", - ]); - const typingCountAfterFinal = transport.typingCount; - await new Promise((resolve) => setTimeout(resolve, 30)); - expect(transport.typingCount).toBe(typingCountAfterFinal); - await bridge.stop(); - }); - - test("starts a thread from a bot DM by a global user outside allowed guild channels", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(), - config: testConfig({ allowedChannelIds: new Set(["guild-parent-channel"]) }), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "threadStart", - sourceMessageId: "message-dm-1", - channelId: "bot-dm-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - title: "DM request", - prompt: "Handle this from DM.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - - await waitFor(() => client.startTurnCalls.length === 1); - expect(transport.createdThreads).toEqual([ - { - channelId: "bot-dm-channel", - name: "DM request", - sourceMessageId: "message-dm-1", - }, - ]); - expect(bridge.stateForTest().sessions[0]).toEqual( - expect.objectContaining({ - discordThreadId: "discord-thread-1", - parentChannelId: "bot-dm-channel", - guildId: undefined, - }), - ); - await bridge.stop(); - }); - - test("can use commentary messages as progress and keep final output phase-aware", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const consoleOutput = new FakeConsoleOutput(); - const store = new MemoryStateStore(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig({ progressMode: "commentary" }), - now: () => new Date("2026-05-11T00:00:00.000Z"), - consoleOutput, - }); - - await bridge.start(); - transport.emit({ - kind: "threadStart", - sourceMessageId: "message-mention-2", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - title: "Scan repo", - prompt: "Scan this repo.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - - await waitFor(() => client.startTurnCalls.length === 1); - const messageCountAfterStart = transport.messages.length; - client.emitNotification({ - method: "item/reasoning/summaryPartAdded", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - itemId: "reasoning-1", - summaryIndex: 0, - }, - }); - client.emitNotification({ - method: "item/reasoning/summaryTextDelta", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - itemId: "reasoning-1", - summaryIndex: 0, - delta: "Reasoning summary should not post.", - }, - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(transport.messages).toHaveLength(messageCountAfterStart); - - client.emitNotification({ - method: "item/agentMessage/delta", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - itemId: "commentary-1", - delta: "I will scan the repo.", - }, - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect( - transport.messages.some((message) => - message.text === "I will scan the repo." - ), - ).toBe(false); - client.emitNotification({ - method: "item/completed", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - item: { - id: "commentary-1", - type: "agentMessage", - text: "I will scan the repo.", - phase: "commentary", - memoryCitation: null, - }, - }, - }); - await waitFor(() => - transport.messages.some((message) => - message.text === "I will scan the repo." - ) - ); - expect(consoleOutput.messages).toEqual([ - expect.objectContaining({ - kind: "commentary", - text: "I will scan the repo.", - discordThreadId: "discord-thread-1", - codexThreadId: "codex-thread-1", - turnId: "turn-1", - title: "Scan repo", - }), - ]); - - client.emitNotification({ - method: "item/agentMessage/delta", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - itemId: "final-1", - delta: "Repo scan complete.", - }, - }); - client.emitNotification({ - method: "item/completed", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - item: { - id: "final-1", - type: "agentMessage", - text: "Repo scan complete.", - phase: "final_answer", - memoryCitation: null, - }, - }, - }); - client.emitNotification({ - method: "turn/completed", - params: { - threadId: "codex-thread-1", - turn: { - id: "turn-1", - items: [ - { - id: "commentary-1", - type: "agentMessage", - text: "I will scan the repo.", - phase: "commentary", - memoryCitation: null, - }, - { - id: "final-1", - type: "agentMessage", - text: "Repo scan complete.", - phase: "final_answer", - memoryCitation: null, - }, - ], - }, - }, - }); - - await waitFor(() => - transport.messages.some((message) => message.text === "Repo scan complete.") - ); - expect(consoleOutput.messages).toEqual([ - expect.objectContaining({ - kind: "commentary", - text: "I will scan the repo.", - }), - expect.objectContaining({ - kind: "final", - text: "Repo scan complete.", - turnId: "turn-1", - title: "Scan repo", - }), - ]); - await waitFor(() => transport.deletedMessages.length === 1); - expect(transport.deletedMessages[0]?.text).toBe("I will scan the repo."); - expect(bridge.stateForTest().deliveries.map((delivery) => delivery.kind)).toEqual([ - "commentary", - "final", - ]); - expect( - transport.messages.some((message) => - message.text.includes("Reasoning summary should not post") - ), - ).toBe(false); - expect( - transport.messages.some((message) => - message.text === "I will scan the repo." - ), - ).toBe(false); - expect( - transport.messages.filter((message) => - message.text === "Repo scan complete." - ), - ).toHaveLength(1); - await bridge.stop(); - }); - - test("grants mentioned users access only to the created Discord thread", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig({ allowedUserIds: new Set(["user-1", "user-admin"]) }), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "threadStart", - sourceMessageId: "message-grant-start", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - prompt: "<@user-2> <@!user-3> Please investigate this repo.", - mentionedUserIds: ["user-2", "user-3", "user-1", "user-2"], - createdAt: "2026-05-11T00:00:00.000Z", - }); - - await waitFor(() => client.startTurnCalls.length === 1); - expect(transport.createdThreads).toEqual([ - { - channelId: "parent-channel", - name: "Please investigate this repo.", - sourceMessageId: "message-grant-start", - }, - ]); - expect(transport.addedThreadMembers).toEqual([ - { channelId: "discord-thread-1", userIds: ["user-2", "user-3"] }, - ]); - const initialPrompt = inputText(client.startTurnCalls[0]?.input[0]); - expect(initialPrompt).toContain("Please investigate this repo."); - expect(initialPrompt).not.toContain("<@user-2>"); - expect(initialPrompt).not.toContain("<@!user-3>"); - expect(bridge.stateForTest().sessions[0]).toEqual( - expect.objectContaining({ - ownerUserId: "user-1", - participantUserIds: ["user-2", "user-3"], - }), - ); - - transport.emit({ - kind: "message", - channelId: "discord-thread-1", - messageId: "message-from-grantee", - author: { id: "user-2", name: "Grace", isBot: false }, - content: "Here is more context.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await waitFor(() => client.steerTurnCalls.length === 1); - expect(client.steerTurnCalls[0]?.input[0]).toEqual( - expect.objectContaining({ - text: expect.stringContaining("Here is more context."), - }), - ); - - transport.emit({ - kind: "message", - channelId: "discord-thread-1", - messageId: "message-from-rando", - author: { id: "user-4", name: "Edsger", isBot: false }, - content: "I should not reach Codex.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(client.steerTurnCalls).toHaveLength(1); - - transport.emit({ - kind: "message", - channelId: "discord-thread-1", - messageId: "message-from-admin", - author: { id: "user-admin", name: "Admin", isBot: false }, - content: "Admin context should reach Codex.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await waitFor(() => client.steerTurnCalls.length === 2); - expect(client.steerTurnCalls[1]?.input[0]).toEqual( - expect.objectContaining({ - text: expect.stringContaining("Admin context should reach Codex."), - }), - ); - - transport.emit({ - kind: "threadStart", - sourceMessageId: "message-grantee-start-denied", - channelId: "parent-channel", - author: { id: "user-2", name: "Grace", isBot: false }, - prompt: "Start a second thread.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(transport.createdThreads).toHaveLength(1); - expect(client.startThreadCalls).toHaveLength(1); - await bridge.stop(); - }); - - test("stores per-thread directories and pins a status message for new threads", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig({ - allowedUserIds: new Set(["user-1", "user-admin"]), - approvalPolicy: "on-request", - sandbox: "workspace-write", - }), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "threadStart", - sourceMessageId: "message-dir-start", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - prompt: "--dir ~/game-protocol-workspace Build the parser.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - - await waitFor(() => client.startTurnCalls.length === 1); - const expectedCwd = path.join(os.homedir(), "game-protocol-workspace"); - expect(client.startThreadCalls[0]?.cwd).toBe(expectedCwd); - expect(client.startTurnCalls[0]?.cwd).toBe(expectedCwd); - expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( - "Build the parser.", - ); - expect(inputText(client.startTurnCalls[0]?.input[0])).not.toContain("--dir"); - expect(bridge.stateForTest().sessions[0]).toEqual( - expect.objectContaining({ - cwd: expectedCwd, - mode: "new", - statusMessageId: "message-out-1", - }), - ); - expect(transport.pinnedMessages).toEqual([ - { channelId: "discord-thread-1", messageId: "message-out-1" }, - ]); - const statusText = transport.messages.find((message) => - message.id === "message-out-1" - )?.text ?? ""; - expect(statusText).toContain("**Codex Discord Bridge**"); - expect(statusText).toContain(`Dir: \`${expectedCwd}\``); - expect(statusText).toContain("Global admins: <@user-1>, <@user-admin>"); - expect(statusText).toContain("Permissions: approval `on-request`"); - - client.emitNotification({ - method: "item/completed", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - item: { - id: "message-final", - type: "agentMessage", - text: "First turn done.", - phase: "final_answer", - memoryCitation: null, - }, - }, - }); - client.emitNotification({ - method: "turn/completed", - params: { - threadId: "codex-thread-1", - turn: { id: "turn-1" }, - }, - }); - await waitFor(() => bridge.stateForTest().queue.length === 0); - transport.emit({ - kind: "message", - channelId: "discord-thread-1", - messageId: "message-follow-up", - author: { id: "user-1", name: "Ada", isBot: false }, - content: "Continue in the same directory.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await waitFor(() => client.startTurnCalls.length === 2); - expect(client.startTurnCalls[1]?.cwd).toBe(expectedCwd); - await bridge.stop(); - }); - - test("resumes arbitrary Codex threads without prompting and replays the last final message", async () => { - const client = new FakeCodexClient(); - const resumedThreadId = "019e1951-5355-78d2-8162-3b2b11dfc4a5"; - client.threadTurns.set(resumedThreadId, [ - { - id: "turn-old-1", - status: "completed", - items: [ - { - type: "agentMessage", - id: "old-final", - text: "Earlier answer.", - phase: "final_answer", - memoryCitation: null, - }, - ], - } as unknown as v2.Turn, - { - id: "turn-old-2", - status: "completed", - items: [ - { - type: "agentMessage", - id: "latest-commentary", - text: "This is commentary.", - phase: "commentary", - memoryCitation: null, - }, - { - type: "agentMessage", - id: "latest-final", - text: "Latest final answer.", - phase: "final_answer", - memoryCitation: null, - }, - ], - } as unknown as v2.Turn, - ]); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "threadStart", - sourceMessageId: "message-resume-start", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - prompt: `resume ${resumedThreadId} --dir ~/game-protocol-workspace`, - createdAt: "2026-05-11T00:00:00.000Z", - }); - - await waitFor(() => - transport.messages.some((message) => message.text === "Latest final answer.") - ); - const expectedCwd = path.join(os.homedir(), "game-protocol-workspace"); - expect(client.resumeThreadCalls).toHaveLength(1); - expect(client.resumeThreadCalls[0]).toEqual( - expect.objectContaining({ - threadId: resumedThreadId, - cwd: expectedCwd, - }), - ); - expect(client.startThreadCalls).toHaveLength(0); - expect(client.startTurnCalls).toHaveLength(0); - expect(client.setThreadNameCalls).toHaveLength(0); - expect(bridge.stateForTest().sessions[0]).toEqual( - expect.objectContaining({ - codexThreadId: resumedThreadId, - cwd: expectedCwd, - mode: "resumed", - statusMessageId: "message-out-1", - }), - ); - expect(transport.pinnedMessages).toEqual([ - { channelId: "discord-thread-1", messageId: "message-out-1" }, - ]); - expect(transport.messages[0]?.text).toContain("Mode: `resumed`"); - expect(transport.messages[0]?.text).toContain(`Dir: \`${expectedCwd}\``); - expect(transport.messages.map((message) => message.text)).toContain( - "Latest final answer.", - ); - await bridge.stop(); - }); - - test("ignores historical progress notifications after resume replay", async () => { - const client = new FakeCodexClient(); - const resumedThreadId = "019e1951-5355-78d2-8162-3b2b11dfc4a5"; - const completedTurn = { - id: "turn-history-1", - status: "completed", - items: [ - { - id: "latest-final", - type: "agentMessage", - text: "Latest final answer.", - phase: "final_answer", - memoryCitation: null, - }, - ], - } as unknown as v2.Turn; - client.threadTurns.set(resumedThreadId, [completedTurn]); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig({ progressMode: "commentary" }), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "threadStart", - sourceMessageId: "message-resume-history", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - prompt: `resume ${resumedThreadId}`, - createdAt: "2026-05-11T00:00:00.000Z", - }); - - await waitFor(() => - bridge.stateForTest().processedMessageIds.includes("message-resume-history") - ); - const messagesAfterResume = transport.messages.map((message) => message.text); - expect( - messagesAfterResume.filter((message) => message === "Latest final answer."), - ).toHaveLength(1); - - client.emitNotification({ - method: "item/completed", - params: { - threadId: resumedThreadId, - turnId: "turn-history-1", - itemId: "historical-commentary", - item: { - id: "historical-commentary", - type: "agentMessage", - text: "Historical commentary.", - phase: "commentary", - memoryCitation: null, - }, - }, - } as JsonRpcNotification); - client.emitNotification({ - method: "turn/completed", - params: { - threadId: resumedThreadId, - turnId: "turn-history-1", - turn: completedTurn, - }, - } as JsonRpcNotification); - await sleep(50); - - expect(transport.messages.map((message) => message.text)).toEqual( - messagesAfterResume, - ); - expect(transport.deletedMessages).toEqual([]); - expect(bridge.stateForTest().activeTurns).toEqual([]); - await bridge.stop(); - }); - - test("cleans stale historical progress after resume restart", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - transport.messages.push( - { - channelId: "discord-thread-1", - id: "message-stale-commentary-1", - text: "Stale commentary 1.", - }, - { - channelId: "discord-thread-1", - id: "message-stale-commentary-2", - text: "Stale commentary 2.", - }, - ); - const store = new MemoryStateStore({ - ...emptyState(), - sessions: [ - { - discordThreadId: "discord-thread-1", - parentChannelId: "parent-channel", - sourceMessageId: "message-resume-start", - codexThreadId: "codex-thread-resumed", - title: "Resumed thread", - createdAt: "2026-05-11T00:00:00.000Z", - mode: "resumed", - statusMessageId: "message-status-1", - }, - ], - activeTurns: [ - { - turnId: "turn-history-1", - discordThreadId: "discord-thread-1", - codexThreadId: "codex-thread-resumed", - origin: "external", - observedAt: "2026-05-11T00:00:00.000Z", - }, - ], - deliveries: [ - { - discordMessageId: "resume:message-resume-start:turn-history-1", - discordThreadId: "discord-thread-1", - codexThreadId: "codex-thread-resumed", - turnId: "turn-history-1", - kind: "final", - outboundMessageIds: ["message-final-1"], - deliveredAt: "2026-05-11T00:00:00.000Z", - }, - { - discordMessageId: "external:turn-history-1", - discordThreadId: "discord-thread-1", - codexThreadId: "codex-thread-resumed", - turnId: "turn-history-1", - kind: "commentary", - outboundMessageIds: [ - "message-stale-commentary-1", - "message-stale-commentary-2", - ], - deliveredAt: "2026-05-11T00:00:00.000Z", - }, - ], - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - await waitFor(() => transport.deletedMessages.length === 2); - - expect(transport.deletedMessages.map((message) => message.messageId)).toEqual([ - "message-stale-commentary-1", - "message-stale-commentary-2", - ]); - expect(bridge.stateForTest().activeTurns).toEqual([]); - expect( - bridge.stateForTest().deliveries.find( - (delivery) => delivery.kind === "commentary", - )?.outboundMessageIds, - ).toEqual([]); - await bridge.stop(); - }); - - test("resume without dir uses the resumed Codex thread cwd", async () => { - const client = new FakeCodexClient(); - const resumedThreadId = "019e1951-5355-78d2-8162-3b2b11dfc4a5"; - const threadCwd = "/home/peezy/original-thread-workspace"; - client.threadCwds.set(resumedThreadId, threadCwd); - client.threadTurns.set(resumedThreadId, [ - { - id: "turn-old-1", - status: "completed", - items: [ - { - type: "agentMessage", - id: "latest-final", - text: "Original cwd answer.", - phase: "final_answer", - memoryCitation: null, - }, - ], - } as unknown as v2.Turn, - ]); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig({ cwd: "/home/peezy/game-protocol-workspace" }), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "threadStart", - sourceMessageId: "message-resume-no-dir", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - prompt: `resume ${resumedThreadId}`, - createdAt: "2026-05-11T00:00:00.000Z", - }); - - await waitFor(() => - transport.messages.some((message) => message.text === "Original cwd answer.") - ); - expect(client.resumeThreadCalls[0]).toEqual( - expect.objectContaining({ - threadId: resumedThreadId, - cwd: null, - }), - ); - expect(bridge.stateForTest().sessions[0]).toEqual( - expect.objectContaining({ - codexThreadId: resumedThreadId, - cwd: threadCwd, - mode: "resumed", - }), - ); - expect(transport.messages[0]?.text).toContain(`Dir: \`${threadCwd}\``); - await bridge.stop(); - }); - - test("updates pinned status with goal, plan, and running command metadata", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore(); - let now = new Date("2026-05-11T00:00:00.000Z"); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => now, - }); - - await bridge.start(); - transport.emit({ - kind: "threadStart", - sourceMessageId: "message-status-start", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - prompt: "Inspect status updates.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - - await waitFor(() => client.startTurnCalls.length === 1); - client.emitNotification({ - method: "thread/goal/updated", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - goal: { - threadId: "codex-thread-1", - objective: "Ship the Discord bridge status surface", - status: "active", - tokenBudget: null, - tokensUsed: 10, - timeUsedSeconds: 2, - createdAt: 0, - updatedAt: 0, - }, - }, - }); - client.emitNotification({ - method: "turn/plan/updated", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - explanation: null, - plan: [ - { step: "Inspect current bridge", status: "completed" }, - { step: "Implement pinned status", status: "inProgress" }, - ], - }, - }); - client.emitNotification({ - method: "item/started", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - item: { - type: "commandExecution", - id: "command-1", - command: "vp test test/*.test.ts", - cwd: "/workspace", - processId: "process-1", - source: "agent", - status: "inProgress", - commandActions: [], - aggregatedOutput: null, - exitCode: null, - durationMs: null, - }, - }, - }); - - await waitFor(() => { - const text = statusMessageText(transport); - return text.includes("Ship the Discord bridge status surface") && - text.includes("Implement pinned status"); - }); - let statusText = statusMessageText(transport); - expect(statusText).toContain("Goal: `active` Ship the Discord bridge status surface"); - expect(statusText).toContain("- `completed` Inspect current bridge"); - expect(statusText).toContain("- `inProgress` Implement pinned status"); - expect(statusText).toContain("**Running Commands**\nnone"); - expect(statusText).not.toContain("vp test test/*.test.ts"); - - now = new Date("2026-05-11T00:00:05.000Z"); - client.emitNotification({ - method: "item/commandExecution/outputDelta", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - itemId: "command-1", - delta: "test output", - }, - }); - await waitFor(() => - statusMessageText(transport).includes("vp test test/*.test.ts") - ); - statusText = statusMessageText(transport); - expect(statusText).toContain("- `vp test test/*.test.ts`"); - - client.emitNotification({ - method: "item/completed", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - item: { - type: "commandExecution", - id: "command-1", - command: "vp test test/*.test.ts", - cwd: "/workspace", - processId: "process-1", - source: "agent", - status: "completed", - commandActions: [], - aggregatedOutput: "", - exitCode: 0, - durationMs: 10, - }, - }, - }); - await waitFor(() => !statusMessageText(transport).includes("vp test")); - statusText = statusMessageText(transport); - expect(statusText).toContain("**Running Commands**\nnone"); - - const messageCountBeforeActivity = transport.messages.length; - client.emitNotification({ - method: "item/started", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - item: { - type: "fileChange", - id: "patch-1", - changes: [ - { type: "add", path: "/workspace/src/worker.ts", content: "test" }, - { type: "update", path: "/workspace/package.json", content: "test" }, - ], - status: "inProgress", - }, - }, - }); - await waitFor(() => - statusMessageText(transport).includes("files: 2 file changes") - ); - statusText = statusMessageText(transport); - expect(statusText).toContain("**Activity**"); - expect(statusText).toContain("- `inProgress` files: 2 file changes"); - expect(transport.messages).toHaveLength(messageCountBeforeActivity); - - client.emitNotification({ - method: "item/completed", - params: { - threadId: "codex-thread-1", - turnId: "turn-1", - item: { - type: "mcpToolCall", - id: "mcp-1", - server: "github", - tool: "search", - status: "completed", - arguments: {}, - result: null, - error: null, - durationMs: 42, - }, - }, - }); - await waitFor(() => - statusMessageText(transport).includes("mcp: github.search") - ); - statusText = statusMessageText(transport); - expect(statusText).toContain("- `completed` mcp: github.search"); - expect(transport.messages).toHaveLength(messageCountBeforeActivity); - await bridge.stop(); - }); - - test("mirrors external turns on managed threads into Discord", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "threadStart", - sourceMessageId: "message-watch-start", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - title: "Watch external work", - prompt: "", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - expect(client.startTurnCalls).toHaveLength(0); - - client.emitNotification({ - method: "turn/started", - params: { - threadId: "codex-thread-1", - turn: { - id: "external-turn-1", - status: "inProgress", - items: [], - startedAt: 1778457600, - }, - }, - }); - await waitFor(() => - statusMessageText(transport).includes("origin `external`") - ); - expect(bridge.stateForTest().activeTurns[0]).toEqual( - expect.objectContaining({ - turnId: "external-turn-1", - origin: "external", - }), - ); - expect(transport.typingCount).toBeGreaterThan(0); - - client.emitNotification({ - method: "item/reasoning/summaryPartAdded", - params: { - threadId: "codex-thread-1", - turnId: "external-turn-1", - itemId: "reasoning-1", - summaryIndex: 0, - }, - }); - client.emitNotification({ - method: "item/reasoning/summaryTextDelta", - params: { - threadId: "codex-thread-1", - turnId: "external-turn-1", - itemId: "reasoning-1", - summaryIndex: 0, - delta: "External source is working.", - }, - }); - client.emitNotification({ - method: "item/reasoning/summaryPartAdded", - params: { - threadId: "codex-thread-1", - turnId: "external-turn-1", - itemId: "reasoning-1", - summaryIndex: 1, - }, - }); - await waitFor(() => - transport.messages.some((message) => - message.text === "External source is working." - ) - ); - - client.emitNotification({ - method: "item/completed", - params: { - threadId: "codex-thread-1", - turnId: "external-turn-1", - item: { - id: "message-final", - type: "agentMessage", - text: "External final answer.", - phase: "final_answer", - memoryCitation: null, - }, - }, - }); - client.emitNotification({ - method: "turn/completed", - params: { - threadId: "codex-thread-1", - turn: { - id: "external-turn-1", - status: "completed", - items: [], - }, - }, - }); - - await waitFor(() => - transport.messages.some((message) => message.text === "External final answer.") - ); - await waitFor(() => transport.deletedMessages.length === 1); - expect(transport.deletedMessages[0]?.text).toBe("External source is working."); - expect(bridge.stateForTest().activeTurns).toEqual([]); - expect(bridge.stateForTest().deliveries.map((delivery) => delivery.kind)).toEqual([ - "summary", - "final", - ]); - await bridge.stop(); - }); - - test("steers Discord messages into externally started active turns", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "threadStart", - sourceMessageId: "message-cross-source-start", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - title: "Cross source steering", - prompt: "", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await waitFor(() => bridge.stateForTest().sessions.length === 1); - client.emitNotification({ - method: "turn/started", - params: { - threadId: "codex-thread-1", - turn: { - id: "external-turn-1", - status: "inProgress", - items: [], - }, - }, - }); - await waitFor(() => bridge.stateForTest().activeTurns.length === 1); - - transport.emit({ - kind: "message", - channelId: "discord-thread-1", - messageId: "message-steer-external", - author: { id: "user-1", name: "Ada", isBot: false }, - content: "Please include the Discord context.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await waitFor(() => client.steerTurnCalls.length === 1); - expect(client.startTurnCalls).toHaveLength(0); - expect(client.steerTurnCalls[0]).toEqual( - expect.objectContaining({ - threadId: "codex-thread-1", - expectedTurnId: "external-turn-1", - }), - ); - expect(inputText(client.steerTurnCalls[0]?.input[0])).toContain( - "Please include the Discord context.", - ); - expect(bridge.stateForTest().processedMessageIds).toContain( - "message-steer-external", - ); - - client.emitNotification({ - method: "turn/completed", - params: { - threadId: "codex-thread-1", - turn: { - id: "external-turn-1", - status: "completed", - items: [ - { - id: "message-final", - type: "agentMessage", - text: "External turn completed.", - phase: "final_answer", - memoryCitation: null, - }, - ], - }, - }, - }); - await waitFor(() => bridge.stateForTest().activeTurns.length === 0); - - transport.emit({ - kind: "message", - channelId: "discord-thread-1", - messageId: "message-new-turn", - author: { id: "user-1", name: "Ada", isBot: false }, - content: "Start a new Discord turn now.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await waitFor(() => client.startTurnCalls.length === 1); - expect(client.steerTurnCalls).toHaveLength(1); - await bridge.stop(); - }); - - test("recovers persisted external active turns and edits the status message", async () => { - const client = new FakeCodexClient(); - client.threadTurns.set("codex-thread-existing", [ - { - id: "external-turn-recovered", - status: "inProgress", - items: [], - } as unknown as v2.Turn, - ]); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore({ - ...emptyState(), - sessions: [ - { - discordThreadId: "discord-thread-1", - parentChannelId: "parent-channel", - codexThreadId: "codex-thread-existing", - title: "Existing thread", - createdAt: "2026-05-11T00:00:00.000Z", - statusMessageId: "message-status-1", - }, - ], - activeTurns: [ - { - turnId: "external-turn-recovered", - discordThreadId: "discord-thread-1", - codexThreadId: "codex-thread-existing", - origin: "external", - observedAt: "2026-05-11T00:00:00.000Z", - }, - ], - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig({ reconcileIntervalMs: 10 }), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - await waitFor(() => - transport.updatedMessages.some((message) => - message.messageId === "message-status-1" && - message.text.includes("origin `external`") - ) - ); - expect(transport.pinnedMessages).toContainEqual({ - channelId: "discord-thread-1", - messageId: "message-status-1", - }); - expect(transport.typingCount).toBeGreaterThan(0); - - client.threadTurns.set("codex-thread-existing", [ - { - id: "external-turn-recovered", - status: "completed", - items: [ - { - id: "message-final", - type: "agentMessage", - text: "Recovered external final.", - phase: "final_answer", - memoryCitation: null, - }, - ], - } as unknown as v2.Turn, - ]); - - await waitFor(() => - transport.messages.some((message) => message.text === "Recovered external final.") - ); - expect(bridge.stateForTest().activeTurns).toEqual([]); - await bridge.stop(); - }); - - test("clear deletes inactive managed threads and preserves running threads", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const replies: string[] = []; - const store = new MemoryStateStore({ - ...emptyState(), - sessions: [ - { - discordThreadId: "discord-thread-idle", - parentChannelId: "parent-channel", - sourceMessageId: "message-idle-start", - codexThreadId: "codex-thread-idle", - title: "Idle", - createdAt: "2026-05-11T00:00:00.000Z", - }, - { - discordThreadId: "discord-thread-active", - parentChannelId: "parent-channel", - codexThreadId: "codex-thread-active", - title: "Active", - createdAt: "2026-05-11T00:00:00.000Z", - }, - { - discordThreadId: "discord-thread-pending", - parentChannelId: "parent-channel", - codexThreadId: "codex-thread-pending", - title: "Pending", - createdAt: "2026-05-11T00:00:00.000Z", - }, - { - discordThreadId: "discord-thread-failed", - parentChannelId: "parent-channel", - sourceMessageId: "message-failed-start", - codexThreadId: "codex-thread-failed", - title: "Failed", - createdAt: "2026-05-11T00:00:00.000Z", - }, - ], - activeTurns: [ - { - turnId: "turn-active", - discordThreadId: "discord-thread-active", - codexThreadId: "codex-thread-active", - origin: "external", - observedAt: "2026-05-11T00:00:00.000Z", - }, - ], - queue: [ - { - id: "queue-pending", - status: "pending", - discordMessageId: "message-pending", - discordThreadId: "discord-thread-pending", - codexThreadId: "codex-thread-pending", - authorId: "user-1", - authorName: "Ada", - content: "Pending work.", - createdAt: "2026-05-11T00:00:00.000Z", - receivedAt: "2026-05-11T00:00:00.000Z", - attempts: 0, - }, - { - id: "queue-failed", - status: "failed", - discordMessageId: "message-failed", - discordThreadId: "discord-thread-failed", - codexThreadId: "codex-thread-failed", - authorId: "user-1", - authorName: "Ada", - content: "Failed work.", - createdAt: "2026-05-11T00:00:00.000Z", - receivedAt: "2026-05-11T00:00:00.000Z", - attempts: 3, - }, - ], - deliveries: [ - { - discordMessageId: "message-idle", - discordThreadId: "discord-thread-idle", - codexThreadId: "codex-thread-idle", - kind: "final", - outboundMessageIds: ["message-out-idle"], - deliveredAt: "2026-05-11T00:00:00.000Z", - }, - ], - }); - transport.messages.push( - { - channelId: "parent-channel", - id: "message-idle-start", - text: "<@bot> scan idle", - }, - { - channelId: "parent-channel", - id: "message-failed-start", - text: "<@bot> scan failed", - }, - ); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "clear", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - createdAt: "2026-05-11T00:00:00.000Z", - reply: async (text) => { - replies.push(text); - }, - }); - await waitFor(() => replies.length === 1); - - expect(transport.deletedThreads).toEqual([ - "discord-thread-idle", - "discord-thread-failed", - ]); - expect( - transport.deletedMessages.map(({ channelId, messageId }) => ({ - channelId, - messageId, - })), - ).toEqual([ - { channelId: "parent-channel", messageId: "message-idle-start" }, - { channelId: "parent-channel", messageId: "message-failed-start" }, - ]); - expect(replies[0]).toBe( - "Deleted 2 inactive Discord threads. Left 2 running threads alone.", - ); - expect(bridge.stateForTest().sessions.map((session) => session.discordThreadId)) - .toEqual(["discord-thread-active", "discord-thread-pending"]); - expect(bridge.stateForTest().queue.map((item) => item.discordThreadId)) - .toEqual(["discord-thread-pending"]); - expect(bridge.stateForTest().deliveries).toEqual([]); - await bridge.stop(); - }); - - test("clear only deletes inactive managed threads in the command guild", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const replies: string[] = []; - const store = new MemoryStateStore({ - ...emptyState(), - sessions: [ - { - discordThreadId: "discord-thread-guild-a-idle", - parentChannelId: "parent-channel-a", - guildId: "guild-a", - codexThreadId: "codex-thread-guild-a-idle", - title: "Guild A idle", - createdAt: "2026-05-11T00:00:00.000Z", - }, - { - discordThreadId: "discord-thread-guild-a-active", - parentChannelId: "parent-channel-a", - guildId: "guild-a", - codexThreadId: "codex-thread-guild-a-active", - title: "Guild A active", - createdAt: "2026-05-11T00:00:00.000Z", - }, - { - discordThreadId: "discord-thread-guild-b-idle", - parentChannelId: "parent-channel-b", - guildId: "guild-b", - codexThreadId: "codex-thread-guild-b-idle", - title: "Guild B idle", - createdAt: "2026-05-11T00:00:00.000Z", - }, - ], - activeTurns: [ - { - turnId: "turn-guild-a-active", - discordThreadId: "discord-thread-guild-a-active", - codexThreadId: "codex-thread-guild-a-active", - origin: "external", - observedAt: "2026-05-11T00:00:00.000Z", - }, - ], - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "clear", - channelId: "parent-channel-a", - guildId: "guild-a", - author: { id: "user-1", name: "Ada", isBot: false }, - createdAt: "2026-05-11T00:00:00.000Z", - reply: async (text) => { - replies.push(text); - }, - }); - await waitFor(() => replies.length === 1); - - expect(transport.deletedThreads).toEqual(["discord-thread-guild-a-idle"]); - expect(replies[0]).toBe( - "Deleted 1 inactive Discord thread. Left 1 running thread alone.", - ); - expect(bridge.stateForTest().sessions.map((session) => session.discordThreadId)) - .toEqual(["discord-thread-guild-a-active", "discord-thread-guild-b-idle"]); - await bridge.stop(); - }); - - test("clear from a bot DM by a global user deletes inactive threads across guilds", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const replies: string[] = []; - const store = new MemoryStateStore({ - ...emptyState(), - sessions: [ - { - discordThreadId: "discord-thread-guild-a-idle", - parentChannelId: "parent-channel-a", - guildId: "guild-a", - codexThreadId: "codex-thread-guild-a-idle", - title: "Guild A idle", - createdAt: "2026-05-11T00:00:00.000Z", - }, - { - discordThreadId: "discord-thread-guild-b-idle", - parentChannelId: "parent-channel-b", - guildId: "guild-b", - codexThreadId: "codex-thread-guild-b-idle", - title: "Guild B idle", - createdAt: "2026-05-11T00:00:00.000Z", - }, - { - discordThreadId: "discord-thread-guild-b-active", - parentChannelId: "parent-channel-b", - guildId: "guild-b", - codexThreadId: "codex-thread-guild-b-active", - title: "Guild B active", - createdAt: "2026-05-11T00:00:00.000Z", - }, - ], - activeTurns: [ - { - turnId: "turn-guild-b-active", - discordThreadId: "discord-thread-guild-b-active", - codexThreadId: "codex-thread-guild-b-active", - origin: "external", - observedAt: "2026-05-11T00:00:00.000Z", - }, - ], - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "clear", - channelId: "bot-dm-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - createdAt: "2026-05-11T00:00:00.000Z", - reply: async (text) => { - replies.push(text); - }, - }); - await waitFor(() => replies.length === 1); - - expect(transport.deletedThreads).toEqual([ - "discord-thread-guild-a-idle", - "discord-thread-guild-b-idle", - ]); - expect(replies[0]).toBe( - "Deleted 2 inactive Discord threads. Left 1 running thread alone.", - ); - expect(bridge.stateForTest().sessions.map((session) => session.discordThreadId)) - .toEqual(["discord-thread-guild-b-active"]); - await bridge.stop(); - }); - - test("clear is restricted to global allowed users", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const replies: string[] = []; - const store = new MemoryStateStore({ - ...emptyState(), - sessions: [ - { - discordThreadId: "discord-thread-idle", - parentChannelId: "parent-channel", - codexThreadId: "codex-thread-idle", - title: "Idle", - createdAt: "2026-05-11T00:00:00.000Z", - }, - ], - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "clear", - channelId: "parent-channel", - author: { id: "user-2", name: "Grace", isBot: false }, - createdAt: "2026-05-11T00:00:00.000Z", - reply: async (text) => { - replies.push(text); - }, - }); - await waitFor(() => replies.length === 1); - - expect(transport.deletedThreads).toEqual([]); - expect(replies[0]).toBe( - "Only globally allowed Discord users can clear bridge threads.", - ); - expect(bridge.stateForTest().sessions).toHaveLength(1); - await bridge.stop(); - }); - - test("clear webhooks deletes webhook messages in the command channel", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const replies: string[] = []; - transport.messages.push( - { - channelId: "parent-channel", - id: "message-webhook-1", - text: "bridged output", - webhookId: "webhook-1", - }, - { - channelId: "parent-channel", - id: "message-user-1", - text: "human message", - }, - { - channelId: "other-channel", - id: "message-webhook-2", - text: "other output", - webhookId: "webhook-1", - }, - ); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(emptyState()), - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "clearWebhooks", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - createdAt: "2026-05-11T00:00:00.000Z", - reply: async (text) => { - replies.push(text); - }, - }); - await waitFor(() => replies.length === 1); - - expect(transport.deletedMessages.map(({ channelId, messageId }) => ({ - channelId, - messageId, - }))).toEqual([{ channelId: "parent-channel", messageId: "message-webhook-1" }]); - expect(transport.messages.map((message) => message.id)).toEqual([ - "message-user-1", - "message-webhook-2", - ]); - expect(replies[0]).toBe("Deleted 1 webhook message."); - await bridge.stop(); - }); - - test("clear webhooks can filter by webhook url", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const replies: string[] = []; - transport.messages.push( - { - channelId: "parent-channel", - id: "message-webhook-1", - text: "first output", - webhookId: "1234567890", - }, - { - channelId: "parent-channel", - id: "message-webhook-2", - text: "second output", - webhookId: "9876543210", - }, - ); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(emptyState()), - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "clearWebhooks", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - webhookUrl: "https://discord.com/api/webhooks/9876543210/token", - createdAt: "2026-05-11T00:00:00.000Z", - reply: async (text) => { - replies.push(text); - }, - }); - await waitFor(() => replies.length === 1); - - expect(transport.deletedMessages.map(({ messageId }) => messageId)).toEqual([ - "message-webhook-2", - ]); - expect(transport.messages.map((message) => message.id)).toEqual([ - "message-webhook-1", - ]); - expect(replies[0]).toBe("Deleted 1 webhook message."); - await bridge.stop(); - }); - - test("clear webhooks is restricted to global allowed users", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const replies: string[] = []; - transport.messages.push({ - channelId: "parent-channel", - id: "message-webhook-1", - text: "bridged output", - webhookId: "webhook-1", - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store: new MemoryStateStore(emptyState()), - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "clearWebhooks", - channelId: "parent-channel", - author: { id: "user-2", name: "Grace", isBot: false }, - createdAt: "2026-05-11T00:00:00.000Z", - reply: async (text) => { - replies.push(text); - }, - }); - await waitFor(() => replies.length === 1); - - expect(transport.deletedMessages).toEqual([]); - expect(replies[0]).toBe( - "Only globally allowed Discord users can clear webhook messages.", - ); - await bridge.stop(); - }); - - test("continues existing managed Discord threads and dedupes messages", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore({ - ...emptyState(), - sessions: [ - { - discordThreadId: "discord-thread-1", - parentChannelId: "parent-channel", - codexThreadId: "codex-thread-existing", - title: "Existing thread", - createdAt: "2026-05-11T00:00:00.000Z", - }, - ], - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "message", - channelId: "discord-thread-1", - messageId: "message-1", - author: { id: "user-1", name: "Ada", isBot: false }, - content: "Continue here.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - - await waitFor(() => client.startTurnCalls.length === 1); - expect(client.startThreadCalls).toHaveLength(0); - expect(client.startTurnCalls[0]?.threadId).toBe("codex-thread-existing"); - expect(client.startTurnCalls[0]?.input[0]).toEqual( - expect.objectContaining({ - text: expect.stringContaining("Message: message-1"), - }), - ); - - transport.emit({ - kind: "message", - channelId: "discord-thread-1", - messageId: "message-1", - author: { id: "user-1", name: "Ada", isBot: false }, - content: "Continue here.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(client.startTurnCalls).toHaveLength(1); - await bridge.stop(); - }); - - test("dedupes replayed mention starts before creating another thread", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore(); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - const mentionStart: DiscordInbound = { - kind: "threadStart", - sourceMessageId: "mention-replay-1", - channelId: "parent-channel", - author: { id: "user-1", name: "Ada", isBot: false }, - prompt: "Please inspect this once.", - createdAt: "2026-05-11T00:00:00.000Z", - }; - - await bridge.start(); - transport.emit(mentionStart); - transport.emit(mentionStart); - - await waitFor(() => client.startTurnCalls.length === 1); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(transport.createdThreads).toHaveLength(1); - expect(client.startThreadCalls).toHaveLength(1); - expect(client.startTurnCalls).toHaveLength(1); - await bridge.stop(); - }); - - test("steers an active turn in one Discord thread without blocking another thread", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore({ - ...emptyState(), - sessions: [ - { - discordThreadId: "discord-thread-1", - parentChannelId: "parent-channel", - codexThreadId: "codex-thread-1", - title: "Thread one", - createdAt: "2026-05-11T00:00:00.000Z", - }, - { - discordThreadId: "discord-thread-2", - parentChannelId: "parent-channel", - codexThreadId: "codex-thread-2", - title: "Thread two", - createdAt: "2026-05-11T00:00:00.000Z", - }, - ], - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "message", - channelId: "discord-thread-1", - messageId: "message-a1", - author: { id: "user-1", name: "Ada", isBot: false }, - content: "First same-thread message.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await waitFor(() => client.startTurnCalls.length === 1); - await waitFor(() => - bridge.stateForTest().queue.some((item) => - item.discordMessageId === "message-a1" && - item.status === "processing" && - item.turnId === "turn-1" - ) - ); - - transport.emit({ - kind: "message", - channelId: "discord-thread-1", - messageId: "message-a2", - author: { id: "user-1", name: "Ada", isBot: false }, - content: "Second same-thread message.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(client.startTurnCalls).toHaveLength(1); - expect(client.steerTurnCalls).toHaveLength(1); - expect(client.steerTurnCalls[0]).toEqual( - expect.objectContaining({ - threadId: "codex-thread-1", - expectedTurnId: "turn-1", - }), - ); - expect(client.steerTurnCalls[0]?.input[0]).toEqual( - expect.objectContaining({ - text: expect.stringContaining("Second same-thread message."), - }), - ); - expect(bridge.stateForTest().processedMessageIds).toContain("message-a2"); - - transport.emit({ - kind: "message", - channelId: "discord-thread-2", - messageId: "message-b1", - author: { id: "user-1", name: "Ada", isBot: false }, - content: "Other thread message.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await waitFor(() => client.startTurnCalls.length === 2); - expect(client.startTurnCalls.map((call) => call.threadId)).toEqual([ - "codex-thread-1", - "codex-thread-2", - ]); - - await waitFor(() => - bridge.stateForTest().queue.filter((item) => item.status === "processing") - .length === 2 - ); - expect( - bridge.stateForTest().queue.filter((item) => item.status === "pending") - .map((item) => item.discordMessageId), - ).toEqual([]); - await bridge.stop(); - }); - - test("reconciles a completed persisted turn on startup", async () => { - const client = new FakeCodexClient(); - client.threadTurns.set("codex-thread-existing", [ - { - id: "turn-recovered", - status: "completed", - items: [ - { - type: "agentMessage", - id: "message-final", - text: "Recovered final answer.", - phase: "final_answer", - memoryCitation: null, - }, - ], - } as unknown as v2.Turn, - ]); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore({ - ...emptyState(), - sessions: [ - { - discordThreadId: "discord-thread-1", - parentChannelId: "parent-channel", - codexThreadId: "codex-thread-existing", - title: "Existing thread", - createdAt: "2026-05-11T00:00:00.000Z", - }, - ], - queue: [ - { - id: "queue-1", - status: "processing", - discordMessageId: "message-1", - discordThreadId: "discord-thread-1", - codexThreadId: "codex-thread-existing", - authorId: "user-1", - authorName: "Ada", - content: "Recover this.", - createdAt: "2026-05-11T00:00:00.000Z", - receivedAt: "2026-05-11T00:00:00.000Z", - attempts: 0, - turnId: "turn-recovered", - }, - ], - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig(), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - await waitFor(() => - transport.messages.some((message) => message.text === "Recovered final answer.") - ); - expect(bridge.stateForTest().queue).toEqual([]); - expect(bridge.stateForTest().processedMessageIds).toContain("message-1"); - expect(bridge.stateForTest().deliveries.map((delivery) => delivery.kind)).toEqual([ - "final", - ]); - await bridge.stop(); - }); - - test("reconciles an active turn when the completion notification is missed", async () => { - const client = new FakeCodexClient(); - const transport = new FakeDiscordTransport(); - const store = new MemoryStateStore({ - ...emptyState(), - sessions: [ - { - discordThreadId: "discord-thread-1", - parentChannelId: "parent-channel", - codexThreadId: "codex-thread-existing", - title: "Existing thread", - createdAt: "2026-05-11T00:00:00.000Z", - }, - ], - }); - const bridge = new DiscordCodexBridge({ - client, - transport, - store, - config: testConfig({ reconcileIntervalMs: 10 }), - now: () => new Date("2026-05-11T00:00:00.000Z"), - }); - - await bridge.start(); - transport.emit({ - kind: "message", - channelId: "discord-thread-1", - messageId: "message-1", - author: { id: "user-1", name: "Ada", isBot: false }, - content: "Complete without a notification.", - createdAt: "2026-05-11T00:00:00.000Z", - }); - await waitFor(() => client.startTurnCalls.length === 1); - client.threadTurns.set("codex-thread-existing", [ - { - id: "turn-1", - status: "completed", - items: [ - { - type: "agentMessage", - id: "message-final", - text: "Recovered by polling.", - phase: "final_answer", - memoryCitation: null, - }, - ], - } as unknown as v2.Turn, - ]); - - await waitFor(() => - transport.messages.some((message) => message.text === "Recovered by polling.") - ); - expect(bridge.stateForTest().queue).toEqual([]); - expect(bridge.stateForTest().processedMessageIds).toContain("message-1"); - await bridge.stop(); - }); -}); - -async function testHookSpoolDir(): Promise { - return await mkdtemp(path.join(os.tmpdir(), "discord-bridge-hooks-")); -} - -async function emitHookEvent( - spoolDir: string, - input: { - eventName: string; - sessionId: string; - turnId?: string; - cwd?: string; - prompt?: string; - toolName?: string; - toolInput?: unknown; - lastAssistantMessage?: string; - }, -): Promise { - await writeStopHookSpoolEvent( - { - hook_event_name: input.eventName, - session_id: input.sessionId, - turn_id: input.turnId, - cwd: input.cwd ?? "/workspace", - transcript_path: `/tmp/${input.sessionId}.jsonl`, - prompt: input.prompt, - tool_name: input.toolName, - tool_input: input.toolInput, - last_assistant_message: input.lastAssistantMessage ?? null, - }, - { - spoolDir, - now: () => new Date("2026-05-14T12:00:00.000Z"), - }, - ); -} - -async function emitStopHook( - spoolDir: string, - input: { - sessionId: string; - turnId: string; - lastAssistantMessage?: string; - cwd?: string; - }, -): Promise { - await writeStopHookSpoolEvent( - { - hook_event_name: "Stop", - session_id: input.sessionId, - turn_id: input.turnId, - cwd: input.cwd ?? "/workspace", - transcript_path: `/tmp/${input.sessionId}.jsonl`, - last_assistant_message: input.lastAssistantMessage ?? null, - }, - { - spoolDir, - now: () => new Date("2026-05-14T12:00:00.000Z"), - }, - ); -} - -function testConfig( - overrides: Partial = {}, -): DiscordBridgeConfig { - return { - allowedUserIds: new Set(["user-1"]), - allowedChannelIds: new Set(["parent-channel"]), - statePath: "/tmp/codex-discord-bridge-test/state.json", - cwd: "/workspace", - summary: "auto", - progressMode: "summary", - typingIntervalMs: 10, - ...overrides, - }; -} - -function testWorkspaceKey(cwd: string): string { - return `workspace-${createHash("sha256").update(cwd).digest("hex").slice(0, 12)}`; -} - -function testThread(input: { - id: string; - cwd: string; - name?: string; - preview?: string; - updatedAt?: number; - status?: v2.ThreadStatus; -}): v2.Thread { - return { - id: input.id, - sessionId: input.id, - forkedFromId: null, - preview: input.preview ?? input.name ?? input.id, - ephemeral: false, - modelProvider: "openai", - createdAt: input.updatedAt ?? 1, - updatedAt: input.updatedAt ?? 1, - status: input.status ?? { type: "idle" }, - path: null, - cwd: input.cwd, - cliVersion: "test", - source: "cli", - threadSource: null, - agentNickname: null, - agentRole: null, - gitInfo: null, - name: input.name ?? null, - turns: [], - } as v2.Thread; -} - -class FakeCodexClient implements CodexBridgeClient { - startThreadCalls: v2.ThreadStartParams[] = []; - resumeThreadCalls: v2.ThreadResumeParams[] = []; - setThreadNameCalls: v2.ThreadSetNameParams[] = []; - startTurnCalls: v2.TurnStartParams[] = []; - steerTurnCalls: v2.TurnSteerParams[] = []; - readThreadCalls: v2.ThreadReadParams[] = []; - injectThreadItemsCalls: v2.ThreadInjectItemsParams[] = []; - listThreadsCalls: v2.ThreadListParams[] = []; - getThreadGoalCalls: v2.ThreadGoalGetParams[] = []; - setThreadGoalCalls: v2.ThreadGoalSetParams[] = []; - clearThreadGoalCalls: v2.ThreadGoalClearParams[] = []; - responses: Array<{ id: string | number; result: unknown }> = []; - responseErrors: Array<{ - id: string | number; - code: number; - message: string; - data?: unknown; - }> = []; - threadTurns = new Map(); - threadCwds = new Map(); - threadGoals = new Map(); - threads: v2.Thread[] = []; - failedResumeThreadIds = new Set(); - blockStartTurn = false; - #startTurnResolvers: Array<() => void> = []; - #notificationListeners: Array<(message: JsonRpcNotification) => void> = []; - #requestListeners: Array<(message: JsonRpcRequest) => void> = []; - - async connect(): Promise {} - - close(): void {} - - on( - event: "notification", - listener: (message: JsonRpcNotification) => void, - ): unknown; - on( - event: "request", - listener: (message: JsonRpcRequest) => void, - ): unknown; - on( - event: "notification" | "request", - listener: - | ((message: JsonRpcNotification) => void) - | ((message: JsonRpcRequest) => void), - ): unknown { - if (event === "notification") { - this.#notificationListeners.push( - listener as (message: JsonRpcNotification) => void, - ); - return; - } - this.#requestListeners.push(listener as (message: JsonRpcRequest) => void); - } - - async startThread(params: v2.ThreadStartParams): Promise { - this.startThreadCalls.push(params); - return { - thread: { id: `codex-thread-${this.startThreadCalls.length}` }, - } as v2.ThreadStartResponse; - } - - async resumeThread(params: v2.ThreadResumeParams): Promise { - this.resumeThreadCalls.push(params); - if (this.failedResumeThreadIds.has(params.threadId)) { - throw new Error(`thread not found: ${params.threadId}`); - } - const listedThread = this.threads.find((thread) => thread.id === params.threadId); - const cwd = params.cwd ?? this.threadCwds.get(params.threadId) ?? - listedThread?.cwd ?? "/workspace"; - return { - cwd, - thread: listedThread ?? { - id: params.threadId, - cwd, - turns: this.threadTurns.get(params.threadId) ?? [], - }, - } as unknown as v2.ThreadResumeResponse; - } - - async setThreadName( - params: v2.ThreadSetNameParams, - ): Promise { - this.setThreadNameCalls.push(params); - return {}; - } - - async startTurn(params: v2.TurnStartParams): Promise { - this.startTurnCalls.push(params); - const turnNumber = this.startTurnCalls.length; - if (this.blockStartTurn) { - await new Promise((resolve) => { - this.#startTurnResolvers.push(resolve); - }); - } - return { - turn: { id: `turn-${turnNumber}` }, - } as v2.TurnStartResponse; - } - - async steerTurn(params: v2.TurnSteerParams): Promise { - this.steerTurnCalls.push(params); - return { turnId: params.expectedTurnId }; - } - - async readThread(params: v2.ThreadReadParams): Promise { - this.readThreadCalls.push(params); - return { - thread: { turns: this.threadTurns.get(params.threadId) ?? [] }, - } as unknown as v2.ThreadReadResponse; - } - - async injectThreadItems( - params: v2.ThreadInjectItemsParams, - ): Promise { - this.injectThreadItemsCalls.push(params); - return {}; - } - - async listThreads(params: v2.ThreadListParams): Promise { - this.listThreadsCalls.push(params); - const cwdFilter = Array.isArray(params.cwd) - ? new Set(params.cwd) - : params.cwd - ? new Set([params.cwd]) - : undefined; - const filtered = this.threads.filter((thread) => - !cwdFilter || cwdFilter.has(thread.cwd) - ); - return { - data: filtered.slice(0, params.limit ?? filtered.length), - nextCursor: null, - backwardsCursor: null, - }; - } - - async getThreadGoal( - params: v2.ThreadGoalGetParams, - ): Promise { - this.getThreadGoalCalls.push(params); - return { - goal: this.threadGoals.get(params.threadId) ?? null, - }; - } - - async setThreadGoal( - params: v2.ThreadGoalSetParams, - ): Promise { - this.setThreadGoalCalls.push(params); - const existing = this.threadGoals.get(params.threadId); - const goal: v2.ThreadGoal = { - threadId: params.threadId, - objective: params.objective ?? existing?.objective ?? "Goal", - status: params.status ?? existing?.status ?? "active", - tokenBudget: params.tokenBudget ?? existing?.tokenBudget ?? null, - tokensUsed: existing?.tokensUsed ?? 0, - timeUsedSeconds: existing?.timeUsedSeconds ?? 0, - createdAt: existing?.createdAt ?? 1, - updatedAt: (existing?.updatedAt ?? 1) + 1, - }; - this.threadGoals.set(params.threadId, goal); - return { goal }; - } - - async clearThreadGoal( - params: v2.ThreadGoalClearParams, - ): Promise { - this.clearThreadGoalCalls.push(params); - const cleared = this.threadGoals.delete(params.threadId); - return { cleared }; - } - - respond(id: string | number, result: unknown): void { - this.responses.push({ id, result }); - } - - respondError( - id: string | number, - code: number, - message: string, - data?: unknown, - ): void { - this.responseErrors.push({ id, code, message, data }); - } - - resolveAllStartTurns(): void { - for (const resolve of this.#startTurnResolvers.splice(0)) { - resolve(); - } - } - - emitNotification(message: JsonRpcNotification): void { - for (const listener of this.#notificationListeners) { - listener(message); - } - } - - emitRequest(message: JsonRpcRequest): void { - for (const listener of this.#requestListeners) { - listener(message); - } - } -} - -class FakeDiscordTransport implements DiscordBridgeTransport { - handlers: DiscordBridgeTransportHandlers | undefined; - createdThreads: Array<{ - channelId: string; - name: string; - sourceMessageId?: string; - }> = []; - createdForumPosts: Array<{ - channelId: string; - name: string; - message: string; - threadId: string; - messageId: string; - }> = []; - messages: Array<{ - channelId: string; - id: string; - text: string; - webhookId?: string; - }> = []; - failUpdateMessages = false; - ephemeralPickers: DiscordEphemeralPicker[] = []; - ephemeralUpdates: Array<{ - pickerId: string; - text: string; - }> = []; - updatedMessages: Array<{ - channelId: string; - messageId: string; - text: string; - }> = []; - deletedMessages: Array<{ - channelId: string; - messageId: string; - text: string; - }> = []; - deletedThreads: string[] = []; - addedThreadMembers: Array<{ channelId: string; userIds: string[] }> = []; - addedReactions: Array<{ - channelId: string; - messageId: string; - reactions: string[]; - }> = []; - registeredCommands: DiscordBridgeCommandRegistration[] = []; - pinnedMessages: Array<{ channelId: string; messageId: string }> = []; - typingCount = 0; - - async start(handlers: DiscordBridgeTransportHandlers): Promise { - this.handlers = handlers; - } - - async stop(): Promise {} - - async registerCommands( - options: DiscordBridgeCommandRegistration = {}, - ): Promise { - this.registeredCommands.push(options); - } - - async createThread( - channelId: string, - name: string, - sourceMessageId?: string, - ): Promise { - this.createdThreads.push({ channelId, name, sourceMessageId }); - return `discord-thread-${this.createdThreads.length}`; - } - - async createForumPost( - channelId: string, - name: string, - message: string, - ): Promise<{ threadId: string; messageId?: string }> { - const threadId = `forum-post-${this.createdForumPosts.length + 1}`; - const messageId = threadId; - this.createdForumPosts.push({ - channelId, - name, - message, - threadId, - messageId, - }); - this.messages.push({ channelId: threadId, id: messageId, text: message }); - return { threadId, messageId }; - } - - async sendMessage(channelId: string, text: string): Promise { - const id = `message-out-${this.messages.length + 1}`; - this.messages.push({ channelId, id, text }); - return [id]; - } - - async updateMessage( - channelId: string, - messageId: string, - text: string, - ): Promise { - if (this.failUpdateMessages) { - throw new Error("Discord update failed"); - } - this.updatedMessages.push({ channelId, messageId, text }); - const message = this.messages.find((candidate) => candidate.id === messageId); - if (message) { - message.text = text; - } - } - - async deleteMessage(channelId: string, messageId: string): Promise { - const message = this.messages.find((candidate) => candidate.id === messageId); - if (message) { - this.deletedMessages.push({ channelId, messageId, text: message.text }); - this.messages = this.messages.filter( - (candidate) => candidate.id !== messageId, - ); - } - } - - async deleteWebhookMessages( - channelId: string, - options: { webhookUrl?: string } = {}, - ): Promise<{ deleted: number; failed: number }> { - const webhookId = options.webhookUrl - ? options.webhookUrl.match(/\/webhooks\/([^/]+)/)?.[1] - : undefined; - let deleted = 0; - for (const message of [...this.messages]) { - if (message.channelId !== channelId || !message.webhookId) { - continue; - } - if (webhookId && message.webhookId !== webhookId) { - continue; - } - await this.deleteMessage(channelId, message.id); - deleted += 1; - } - return { deleted, failed: 0 }; - } - - async deleteThread(channelId: string): Promise { - this.deletedThreads.push(channelId); - } - - async addThreadMembers(channelId: string, userIds: string[]): Promise { - this.addedThreadMembers.push({ channelId, userIds }); - } - - async addReactions( - channelId: string, - messageId: string, - reactions: string[], - ): Promise { - this.addedReactions.push({ channelId, messageId, reactions }); - } - - async pinMessage(channelId: string, messageId: string): Promise { - this.pinnedMessages.push({ channelId, messageId }); - } - - async sendTyping(): Promise { - this.typingCount += 1; - } - - emit(inbound: DiscordInbound): void { - this.handlers?.onInbound(inbound); - } - - threadsReplyPicker(): (picker: DiscordEphemeralPicker) => Promise { - return async (picker) => { - this.ephemeralPickers.push(picker); - }; - } - - emitThreadPicker(input: { - pickerId: string; - optionId: string; - authorId?: string; - }): void { - this.emit({ - kind: "threadPicker", - channelId: "ephemeral-command", - pickerId: input.pickerId, - optionId: input.optionId, - author: { - id: input.authorId ?? "user-1", - name: "Peezy", - isBot: false, - }, - createdAt: "2026-05-14T12:00:00.000Z", - update: async (text) => { - this.ephemeralUpdates.push({ pickerId: input.pickerId, text }); - }, - reply: async (text) => { - this.ephemeralUpdates.push({ pickerId: input.pickerId, text }); - }, - updatePicker: async (picker) => { - this.ephemeralUpdates.push({ - pickerId: input.pickerId, - text: picker.text, - }); - this.ephemeralPickers.push(picker); - }, - }); - } - - emitReaction(input: { - channelId: string; - messageId: string; - emoji: string; - authorId?: string; - }): void { - this.emit({ - kind: "reaction", - channelId: input.channelId, - messageId: input.messageId, - emoji: input.emoji, - author: { - id: input.authorId ?? "user-1", - name: "Peezy", - isBot: false, - }, - createdAt: "2026-05-14T12:00:00.000Z", - }); - } -} - -class FakeConsoleOutput implements DiscordConsoleOutput { - messages: DiscordConsoleMessage[] = []; - - message(message: DiscordConsoleMessage): void { - this.messages.push(message); - } -} - -async function waitFor( - predicate: () => boolean | Promise, - timeoutMs = 1000, -): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (await predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error("Timed out waiting for predicate"); -} - -async function sleep(delayMs: number): Promise { - await new Promise((resolve) => setTimeout(resolve, delayMs)); -} - -function inputText(value: unknown): string { - if (typeof value !== "object" || value === null || !("text" in value)) { - return ""; - } - const text = (value as { text?: unknown }).text; - return typeof text === "string" ? text : ""; -} - -function workspaceToolResult(value: unknown): unknown { - if (typeof value !== "object" || value === null || !("contentItems" in value)) { - return undefined; - } - const items = (value as { contentItems?: unknown }).contentItems; - if (!Array.isArray(items)) { - return undefined; - } - const text = inputText(items[0]); - return text ? JSON.parse(text) : undefined; -} - -function statusMessageText(transport: FakeDiscordTransport): string { - return transport.messages.find((message) => message.id === "message-out-1") - ?.text ?? ""; -} diff --git a/apps/discord-bridge/test/config.test.ts b/apps/discord-bridge/test/config.test.ts deleted file mode 100644 index ecbf232..0000000 --- a/apps/discord-bridge/test/config.test.ts +++ /dev/null @@ -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, - ]; -} diff --git a/apps/discord-bridge/test/console-output.test.ts b/apps/discord-bridge/test/console-output.test.ts deleted file mode 100644 index 5a2c4cb..0000000 --- a/apps/discord-bridge/test/console-output.test.ts +++ /dev/null @@ -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; - 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(""); - }, - }; -} diff --git a/apps/discord-bridge/test/hook-cli.test.ts b/apps/discord-bridge/test/hook-cli.test.ts deleted file mode 100644 index af93b6c..0000000 --- a/apps/discord-bridge/test/hook-cli.test.ts +++ /dev/null @@ -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 }); - } - }); -}); diff --git a/apps/discord-bridge/test/logger.test.ts b/apps/discord-bridge/test/logger.test.ts deleted file mode 100644 index 0645ced..0000000 --- a/apps/discord-bridge/test/logger.test.ts +++ /dev/null @@ -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; - 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(""); - }, - }; -} diff --git a/apps/discord-bridge/test/state.test.ts b/apps/discord-bridge/test/state.test.ts deleted file mode 100644 index d9ddd33..0000000 --- a/apps/discord-bridge/test/state.test.ts +++ /dev/null @@ -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 }); - } - }); -}); diff --git a/apps/discord-bridge/test/stop-hook-spool.test.ts b/apps/discord-bridge/test/stop-hook-spool.test.ts deleted file mode 100644 index 6c64dc0..0000000 --- a/apps/discord-bridge/test/stop-hook-spool.test.ts +++ /dev/null @@ -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 }); - } - }); -}); diff --git a/apps/discord-bridge/tsconfig.json b/apps/discord-bridge/tsconfig.json deleted file mode 100644 index 920cc96..0000000 --- a/apps/discord-bridge/tsconfig.json +++ /dev/null @@ -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"] -} diff --git a/apps/workspace-voice-gateway/README.md b/apps/workspace-voice-gateway/README.md deleted file mode 100644 index 81b2b8d..0000000 --- a/apps/workspace-voice-gateway/README.md +++ /dev/null @@ -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. diff --git a/apps/workspace-voice-gateway/package.json b/apps/workspace-voice-gateway/package.json deleted file mode 100644 index c028f21..0000000 --- a/apps/workspace-voice-gateway/package.json +++ /dev/null @@ -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:" - } -} diff --git a/apps/workspace-voice-gateway/scripts/build-package.ts b/apps/workspace-voice-gateway/scripts/build-package.ts deleted file mode 100644 index bb3525f..0000000 --- a/apps/workspace-voice-gateway/scripts/build-package.ts +++ /dev/null @@ -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 { - 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 { - 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): Promise { - return new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", (code) => resolve(code)); - }); -} diff --git a/apps/workspace-voice-gateway/scripts/smoke-bin.ts b/apps/workspace-voice-gateway/scripts/smoke-bin.ts deleted file mode 100644 index 041d4ef..0000000 --- a/apps/workspace-voice-gateway/scripts/smoke-bin.ts +++ /dev/null @@ -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 { - 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): Promise { - return new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", (code) => resolve(code)); - }); -} diff --git a/apps/workspace-voice-gateway/src/announcements.ts b/apps/workspace-voice-gateway/src/announcements.ts deleted file mode 100644 index 1ec02e8..0000000 --- a/apps/workspace-voice-gateway/src/announcements.ts +++ /dev/null @@ -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; -}; - -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 | Record): 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 | 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; -} diff --git a/apps/workspace-voice-gateway/src/announcer.ts b/apps/workspace-voice-gateway/src/announcer.ts deleted file mode 100644 index d81a135..0000000 --- a/apps/workspace-voice-gateway/src/announcer.ts +++ /dev/null @@ -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; - ignoredThreadIds?(): Iterable; - close?(): void; -}; - -export type CodexTurnAnnouncerOptions = { - workspaceBackendUrl: string; - model: string; - reasoningEffort: ReasoningEffort; - timeoutMs: number; - maxPhraseChars: number; - cwd?: string | null; - logger: Logger; - clientOptions?: Partial; -}; - -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 { - 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; - #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 { - return this.#threadId ? [this.#threadId] : []; - } - - async polish(context: TurnCompletionContext): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function parseJsonObject(rawText: string): Record | 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; -} diff --git a/apps/workspace-voice-gateway/src/config.ts b/apps/workspace-voice-gateway/src/config.ts deleted file mode 100644 index 2bdd8e4..0000000 --- a/apps/workspace-voice-gateway/src/config.ts +++ /dev/null @@ -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 = 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 Workspace backend WebSocket URL. - --tts-worker-url TTS worker HTTP URL. - --discord-token Discord bot token. - --discord-guild-id Discord guild id. - --discord-voice-channel-id Discord voice channel id. - --reference-audio-path TTS reference voice audio path. - --reference-text TTS reference transcript. - --reference-text-path TTS reference transcript path. - --announcer Enable model-polished turn-end phrases. - --announcer-model Announcer model override. - --announcer-reasoning-effort Announcer reasoning effort. Defaults to low. - --max-phrase-chars Announcer phrase target. Defaults to 260. - --hook-spool-dir 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 { - const flags = new Map(); - 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, - env: Record, - 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, - env: Record, - 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, - env: Record, - 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}`); -} diff --git a/apps/workspace-voice-gateway/src/discord-voice.ts b/apps/workspace-voice-gateway/src/discord-voice.ts deleted file mode 100644 index 295f50b..0000000 --- a/apps/workspace-voice-gateway/src/discord-voice.ts +++ /dev/null @@ -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; - resource: ReturnType; -}; - -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 { - 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((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 { - 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((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 { - this.#closed = true; - this.#activePlayback?.cleanup(); - this.#connection?.destroy(); - await this.#client?.destroy(); - } - - async #join(cachedGuild: Guild | null): Promise { - 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 { - 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 { - 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 { - 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 { - 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 }; -} diff --git a/apps/workspace-voice-gateway/src/gateway.ts b/apps/workspace-voice-gateway/src/gateway.ts deleted file mode 100644 index 896f4d6..0000000 --- a/apps/workspace-voice-gateway/src/gateway.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - return new Set(this.#announcer?.ignoredThreadIds?.() ?? []); - } -} diff --git a/apps/workspace-voice-gateway/src/hook-spool.ts b/apps/workspace-voice-gateway/src/hook-spool.ts deleted file mode 100644 index 94d839c..0000000 --- a/apps/workspace-voice-gateway/src/hook-spool.ts +++ /dev/null @@ -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(); - #seenEvents = new Set(); - #watcher?: FSWatcher; - #timer?: ReturnType; - #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 { - 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 { - 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 { - 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"; -} diff --git a/apps/workspace-voice-gateway/src/index.ts b/apps/workspace-voice-gateway/src/index.ts deleted file mode 100644 index 847b291..0000000 --- a/apps/workspace-voice-gateway/src/index.ts +++ /dev/null @@ -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 { - 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 { - 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; - }); -} diff --git a/apps/workspace-voice-gateway/src/speech-queue.ts b/apps/workspace-voice-gateway/src/speech-queue.ts deleted file mode 100644 index 112d80d..0000000 --- a/apps/workspace-voice-gateway/src/speech-queue.ts +++ /dev/null @@ -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(); - #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 { - 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 { - this.logger.info("dry-run.speech", { text }); - } -} diff --git a/apps/workspace-voice-gateway/src/text.ts b/apps/workspace-voice-gateway/src/text.ts deleted file mode 100644 index 0c422cf..0000000 --- a/apps/workspace-voice-gateway/src/text.ts +++ /dev/null @@ -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 { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? value as Record - : {}; -} - -export function stringValue(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} diff --git a/apps/workspace-voice-gateway/src/tts-client.ts b/apps/workspace-voice-gateway/src/tts-client.ts deleted file mode 100644 index f51c23b..0000000 --- a/apps/workspace-voice-gateway/src/tts-client.ts +++ /dev/null @@ -1,116 +0,0 @@ -export type TtsWorkerClientOptions = { - workerUrl: string; - referenceAudioPath?: string | null; - referenceText?: string | null; - referenceTextPath?: string | null; -}; - -export type TtsAudioStream = { - body: ReadableStream; - 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 { - 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 { - 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 { - 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; - 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; -} diff --git a/apps/workspace-voice-gateway/src/types.ts b/apps/workspace-voice-gateway/src/types.ts deleted file mode 100644 index eb2fbab..0000000 --- a/apps/workspace-voice-gateway/src/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -export type Logger = { - info(message: string, details?: Record): void; - warn(message: string, details?: Record): void; - error(message: string, details?: Record): void; - debug?(message: string, details?: Record): 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; - speak(text: string): Promise; - close?(): void | Promise; -}; - -function log( - level: "info" | "warn" | "error" | "debug", - message: string, - details?: Record, -): 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); -} diff --git a/apps/workspace-voice-gateway/test/announcements.test.ts b/apps/workspace-voice-gateway/test/announcements.test.ts deleted file mode 100644 index e8e2ea2..0000000 --- a/apps/workspace-voice-gateway/test/announcements.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/apps/workspace-voice-gateway/test/announcer.test.ts b/apps/workspace-voice-gateway/test/announcer.test.ts deleted file mode 100644 index 930366a..0000000 --- a/apps/workspace-voice-gateway/test/announcer.test.ts +++ /dev/null @@ -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("..."); - }); -}); diff --git a/apps/workspace-voice-gateway/test/config.test.ts b/apps/workspace-voice-gateway/test/config.test.ts deleted file mode 100644 index 13431a5..0000000 --- a/apps/workspace-voice-gateway/test/config.test.ts +++ /dev/null @@ -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); - } - }); -}); diff --git a/apps/workspace-voice-gateway/test/gateway.test.ts b/apps/workspace-voice-gateway/test/gateway.test.ts deleted file mode 100644 index 7245b8d..0000000 --- a/apps/workspace-voice-gateway/test/gateway.test.ts +++ /dev/null @@ -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(method: string, _params?: unknown): Promise { - 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 { - 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"); -} diff --git a/apps/workspace-voice-gateway/test/hook-spool.test.ts b/apps/workspace-voice-gateway/test/hook-spool.test.ts deleted file mode 100644 index 856ad14..0000000 --- a/apps/workspace-voice-gateway/test/hook-spool.test.ts +++ /dev/null @@ -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 { - const dir = await mkdtemp(path.join(os.tmpdir(), "voice-hook-spool-")); - tempDirs.push(dir); - return dir; -} - -async function exists(filePath: string): Promise { - try { - await stat(filePath); - return true; - } catch { - return false; - } -} - -const testLogger = { - info() {}, - warn() {}, - error() {}, - debug() {}, -}; diff --git a/apps/workspace-voice-gateway/test/speech-queue.test.ts b/apps/workspace-voice-gateway/test/speech-queue.test.ts deleted file mode 100644 index 6d9c7a6..0000000 --- a/apps/workspace-voice-gateway/test/speech-queue.test.ts +++ /dev/null @@ -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((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 { - 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"); -} diff --git a/apps/workspace-voice-gateway/test/tts-client.test.ts b/apps/workspace-voice-gateway/test/tts-client.test.ts deleted file mode 100644 index 40a0f46..0000000 --- a/apps/workspace-voice-gateway/test/tts-client.test.ts +++ /dev/null @@ -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 | 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; - 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 | 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; - 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 { - return new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(3840)); - controller.close(); - }, - }); -} - -async function createHttpTestServer( - handler: (request: Request) => Response | Promise, -): Promise<{ url: string; close: () => Promise }> { - 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((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 { - 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 { - 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]); -} diff --git a/apps/workspace-voice-gateway/tsconfig.json b/apps/workspace-voice-gateway/tsconfig.json deleted file mode 100644 index ab73d91..0000000 --- a/apps/workspace-voice-gateway/tsconfig.json +++ /dev/null @@ -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"] -} diff --git a/docs/pages/guides/install-codex-plugin.md b/docs/pages/guides/install-codex-plugin.md index 3acc499..5c09f40 100644 --- a/docs/pages/guides/install-codex-plugin.md +++ b/docs/pages/guides/install-codex-plugin.md @@ -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 diff --git a/mprocs.voice.yaml b/mprocs.voice.yaml deleted file mode 100644 index af9154b..0000000 --- a/mprocs.voice.yaml +++ /dev/null @@ -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 diff --git a/mprocs.yaml b/mprocs.yaml deleted file mode 100644 index 9d77193..0000000 --- a/mprocs.yaml +++ /dev/null @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdc2921..6a69bc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9cddb28..6d1b245 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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