diff --git a/apps/discord-bridge/README.md b/apps/discord-bridge/README.md index e07b4e0..23fce0e 100644 --- a/apps/discord-bridge/README.md +++ b/apps/discord-bridge/README.md @@ -4,9 +4,11 @@ Long-lived Discord sidecar for connecting Discord to Codex app-server threads. ## Gateway Mode -Gateway mode is opt-in. It keeps one Discord home channel as the primary UX and -one main Codex thread as the operator memory for the gateway. Legacy -thread-per-task behavior remains available outside the configured home channel. +Gateway 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 gateway. Legacy +thread-per-task behavior remains available outside the configured gateway +channels. Set these environment values before starting the bridge: @@ -21,20 +23,44 @@ CODEX_FLOW_BACKEND_URL=http://127.0.0.1:8090 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.gateway.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.gateway.surfaces]]` entry. + `CODEX_DISCORD_MAIN_THREAD_ID` is optional. If omitted, the bridge creates a new main operator thread, attaches the privileged gateway 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 gateway tools to a thread that predates gateway mode. -In the home channel: +In each configured home channel: - normal messages are sent to the main operator thread - bot mentions are treated as gateway messages and do not create Discord task threads - `/status` replies directly with gateway state instead of starting a Codex turn - `/status` also lists active Codex threads, linking any opened Discord thread - and offering private buttons to open active threads that are not yet in Discord + 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 @@ -78,17 +104,19 @@ gateway tools; only the main operator thread can manage delegation. ## Workbench Prototype -The gateway can optionally maintain a noisy Discord workbench beside the home -channel. Configure both channels to enable it: +The gateway 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.gateway.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. The workspace forum gets one -post for each discoverable top-level folder under `CODEX_DISCORD_DIR`, which is -the gateway's main workspace root. For the home-folder gateway, set +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 +gateway's main workspace root. For the home-folder gateway, 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 @@ -96,8 +124,9 @@ the gateway's main workspace root. For the home-folder gateway, set 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 the task thread channel, and -messages in that Discord thread are routed directly to the opened Codex thread. +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: @@ -110,9 +139,10 @@ When the workbench is enabled: - `/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 the task thread channel -- `/status` shows all active Codex threads across workspaces and uses the same - ephemeral button flow to open active threads without Discord task threads + 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 @@ -122,9 +152,9 @@ When the workbench is enabled: - 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 -- the home channel receives only compact status/link messages for completed - delegations + 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 diff --git a/apps/discord-bridge/src/bridge.ts b/apps/discord-bridge/src/bridge.ts index e79d7d1..82efb6d 100644 --- a/apps/discord-bridge/src/bridge.ts +++ b/apps/discord-bridge/src/bridge.ts @@ -28,6 +28,7 @@ import type { DiscordGatewayHookEvent, DiscordGatewayObservedThread, DiscordGatewayPendingWake, + DiscordGatewaySurfaceConfig, DiscordGatewayWorkspaceSurface, DiscordBridgeSession, DiscordBridgeState, @@ -104,6 +105,18 @@ type WorkspaceGoalActionPicker = { entry: WorkspaceGoalSummary; }; +type GatewaySurface = DiscordGatewaySurfaceConfig & { + workspaceCwds?: string[]; +}; + +type GatewayWorkbenchConfig = { + surfaceKey: string; + workspaceForumChannelId: string; + taskThreadsChannelId: string; +}; + +const defaultGatewaySurfaceKey = "default"; + export class DiscordCodexBridge { readonly client: CodexBridgeClient; readonly transport: DiscordBridgeTransport; @@ -301,7 +314,7 @@ export class DiscordCodexBridge { } if (inbound.kind === "threadStart") { - if (this.config.gateway?.homeChannelId === inbound.channelId) { + if (this.#gatewaySurfaceForHomeChannel(inbound.channelId)) { await this.#handleGatewayThreadStart(inbound); return; } @@ -458,16 +471,19 @@ export class DiscordCodexBridge { await command.reply?.("This Discord channel is not allowed for the bridge."); return; } - const activeThreads = await this.#listActiveCodexThreadSummaries(); + const surface = this.#gatewaySurfaceForChannel(command.channelId) ?? + this.#primaryGatewaySurface(); + const workbench = this.#gatewayWorkbenchConfig(surface); + const activeThreads = await this.#listActiveCodexThreadSummaries(surface); const openableThreads = activeThreads.filter((thread) => !thread.discordThreadId && !this.#isGatewayMainThread(thread.id) && - Boolean(this.#gatewayWorkbenchConfig()) + Boolean(workbench) ).slice(0, threadPickerReactions.length); const statusText = this.#gatewayStatusMessage({ activeThreads, openableThreads, - }); + }, surface); if (openableThreads.length === 0 || !command.replyPicker) { await command.reply?.(statusText); return; @@ -707,6 +723,7 @@ export class DiscordCodexBridge { try { const session = await this.#materializeWorkspaceThread(entry.id, { author: selection.author, + surface: this.#gatewaySurfaceForChannel(picker.channelId), }); await updateOrReply( selection, @@ -758,6 +775,7 @@ export class DiscordCodexBridge { try { const session = await this.#materializeWorkspaceThread(picker.entry.id, { author: selection.author, + surface: this.#gatewaySurfaceForWorkspace(picker.workspace), }); const updatedEntry = { ...picker.entry, @@ -871,6 +889,7 @@ export class DiscordCodexBridge { try { const session = await this.#materializeWorkspaceThread(entry.id, { author: reaction.author, + surface: this.#gatewaySurfaceForChannel(picker.channelId), }); await this.transport.sendMessage( picker.channelId, @@ -1039,7 +1058,7 @@ export class DiscordCodexBridge { }); return; } - if (this.config.gateway?.homeChannelId === message.channelId) { + if (this.#gatewaySurfaceForHomeChannel(message.channelId)) { await this.#handleGatewayMessage(message); return; } @@ -1108,21 +1127,27 @@ export class DiscordCodexBridge { activeThreads?: WorkspaceThreadSummary[]; openableThreads?: WorkspaceThreadSummary[]; } = {}, + surface: GatewaySurface | undefined = this.#primaryGatewaySurface(), ): string { const state = this.#requireState(); const gateway = state.gateway; const session = this.#gatewaySession(); - const delegations = gateway?.delegations ?? []; - const workspaces = gateway?.workspaces ?? []; + const delegations = (gateway?.delegations ?? []).filter((delegation) => + this.#gatewaySurfaceForDelegation(delegation)?.key === surface?.key + ); + const workspaces = (gateway?.workspaces ?? []).filter((workspace) => + this.#gatewaySurfaceForWorkspace(workspace)?.key === surface?.key + ); const activeDelegations = delegations.filter((delegation) => delegation.status === "active" ); - const workbench = this.#gatewayWorkbenchConfig(); + const workbench = this.#gatewayWorkbenchConfig(surface); const activeThreads = options.activeThreads ?? []; const openableThreads = options.openableThreads ?? []; return [ "**Codex Gateway**", - `Home channel: \`${this.config.gateway?.homeChannelId ?? "disabled"}\``, + surface ? `Surface: \`${surface.key}\`` : undefined, + `Home channel: \`${surface?.homeChannelId ?? this.config.gateway?.homeChannelId ?? "disabled"}\``, `Main thread: \`${session?.codexThreadId ?? gateway?.mainThreadId ?? "none"}\``, `Dir: \`${session?.cwd ?? this.config.cwd ?? "default"}\``, `Legacy thread bridge: \`enabled\``, @@ -1302,6 +1327,12 @@ export class DiscordCodexBridge { existing.codexThreadId, gatewayCwd, )); + const primarySurface = this.#primaryGatewaySurface(); + this.#runnersByDiscordThread.delete(existing.discordThreadId); + this.#runnersByCodexThread.delete(existing.codexThreadId); + existing.discordThreadId = gatewayConfig.homeChannelId; + existing.parentChannelId = gatewayConfig.homeChannelId; + existing.surfaceKey = primarySurface?.key; existing.cwd = gatewayCwd ?? resumeResponseCwd(resumed) ?? existing.cwd; state.gateway = { homeChannelId: gatewayConfig.homeChannelId, @@ -1368,6 +1399,7 @@ export class DiscordCodexBridge { createdAt: this.#now().toISOString(), cwd: resumeResponseCwd(started) ?? this.config.cwd, mode: "gateway", + surfaceKey: this.#primaryGatewaySurface()?.key, }; state.gateway = { homeChannelId: gatewayConfig.homeChannelId, @@ -1393,6 +1425,127 @@ export class DiscordCodexBridge { }); } + #gatewaySurfaces(): GatewaySurface[] { + const gateway = this.config.gateway; + if (!gateway) { + return []; + } + if (gateway.surfaces?.length) { + return gateway.surfaces.map((surface) => ({ + ...surface, + workspaceCwds: surface.workspaceCwds?.map((cwd) => + workspaceCwdForPath(cwd, this.config.cwd) + ), + })); + } + return [ + { + key: defaultGatewaySurfaceKey, + homeChannelId: gateway.homeChannelId, + workspaceForumChannelId: gateway.workspaceForumChannelId, + taskThreadsChannelId: gateway.taskThreadsChannelId, + }, + ]; + } + + #primaryGatewaySurface(): GatewaySurface | undefined { + return this.#gatewaySurfaces()[0]; + } + + #gatewaySurfaceByKey(key: string | undefined): GatewaySurface | undefined { + return key + ? this.#gatewaySurfaces().find((surface) => surface.key === key) + : undefined; + } + + #gatewaySurfaceForHomeChannel(channelId: string): GatewaySurface | undefined { + return this.#gatewaySurfaces().find((surface) => + surface.homeChannelId === channelId + ); + } + + #gatewaySurfaceForWorkspaceForumChannel(channelId: string): GatewaySurface | undefined { + return this.#gatewaySurfaces().find((surface) => + surface.workspaceForumChannelId === channelId + ); + } + + #gatewaySurfaceForTaskThreadsChannel(channelId: string): GatewaySurface | undefined { + return this.#gatewaySurfaces().find((surface) => + surface.taskThreadsChannelId === channelId + ); + } + + #gatewaySurfaceForChannel(channelId: string): GatewaySurface | undefined { + return this.#gatewaySurfaceForHomeChannel(channelId) ?? + this.#gatewaySurfaceForWorkspaceForumChannel(channelId) ?? + this.#gatewaySurfaceForTaskThreadsChannel(channelId) ?? + this.#gatewaySurfaceForWorkspace(this.#workspaceForChannel(channelId)) ?? + this.#gatewaySurfaceForSession(this.#requireState().sessions.find((session) => + session.discordThreadId === channelId + )); + } + + #gatewaySurfaceForCwd(cwd: string | undefined): GatewaySurface | undefined { + const surfaces = this.#gatewaySurfaces(); + 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]; + } + + #gatewaySurfaceForWorkspace( + workspace: DiscordGatewayWorkspaceSurface | undefined, + ): GatewaySurface | undefined { + if (!workspace) { + return undefined; + } + return this.#gatewaySurfaceByKey(workspace.surfaceKey) ?? + this.#gatewaySurfaceForCwd(workspace.cwd); + } + + #gatewaySurfaceForDelegation( + delegation: DiscordGatewayDelegation, + ): GatewaySurface | undefined { + return this.#gatewaySurfaceByKey(delegation.surfaceKey) ?? + this.#gatewaySurfaceForCwd(delegation.cwd); + } + + #gatewaySurfaceForObserved( + observed: DiscordGatewayObservedThread, + ): GatewaySurface | undefined { + return this.#gatewaySurfaceByKey(observed.surfaceKey) ?? + this.#gatewaySurfaceForCwd(observed.cwd); + } + + #gatewaySurfaceForSession( + session: DiscordBridgeSession | undefined, + ): GatewaySurface | undefined { + if (!session) { + return undefined; + } + return this.#gatewaySurfaceByKey(session.surfaceKey) ?? + this.#gatewaySurfaceForHomeChannel(session.discordThreadId) ?? + this.#gatewaySurfaceForTaskThreadsChannel(session.parentChannelId) ?? + this.#gatewaySurfaceForWorkspaceForumChannel(session.parentChannelId) ?? + this.#gatewaySurfaceForCwd(session.cwd); + } + #gatewaySession(): DiscordBridgeSession | undefined { const gatewayConfig = this.config.gateway; if (!gatewayConfig) { @@ -1412,7 +1565,9 @@ export class DiscordCodexBridge { } #shouldAutoStartRunner(session: DiscordBridgeSession): boolean { - const workbench = this.#gatewayWorkbenchConfig(); + const workbench = this.#gatewayWorkbenchConfig( + this.#gatewaySurfaceForSession(session), + ); return session.parentChannelId !== workbench?.taskThreadsChannelId; } @@ -1424,16 +1579,16 @@ export class DiscordCodexBridge { ); } - #gatewayWorkbenchConfig(): - | { workspaceForumChannelId: string; taskThreadsChannelId: string } - | undefined { - const gateway = this.config.gateway; - if (!gateway?.workspaceForumChannelId || !gateway.taskThreadsChannelId) { + #gatewayWorkbenchConfig( + surface: GatewaySurface | undefined = this.#primaryGatewaySurface(), + ): GatewayWorkbenchConfig | undefined { + if (!surface?.workspaceForumChannelId || !surface.taskThreadsChannelId) { return undefined; } return { - workspaceForumChannelId: gateway.workspaceForumChannelId, - taskThreadsChannelId: gateway.taskThreadsChannelId, + surfaceKey: surface.key, + workspaceForumChannelId: surface.workspaceForumChannelId, + taskThreadsChannelId: surface.taskThreadsChannelId, }; } @@ -1558,6 +1713,7 @@ export class DiscordCodexBridge { status: prompt ? "active" : "idle", cwd, groupId, + surfaceKey: this.#gatewaySurfaceForCwd(cwd)?.key, returnMode, discordDetailThreadId: stringValue(args.discordDetailThreadId), parentDiscordMessageId: stringValue(args.parentDiscordMessageId), @@ -1598,6 +1754,9 @@ export class DiscordCodexBridge { title: stringValue(args.title) ?? `Delegated ${compactId(codexThreadId)}`, status: "idle", cwd: cwd ?? resumeResponseCwd(resumed), + surfaceKey: this.#gatewaySurfaceForCwd( + cwd ?? resumeResponseCwd(resumed) ?? this.config.cwd, + )?.key, groupId, returnMode: returnModeFromArgs(args, "manual"), discordDetailThreadId: stringValue(args.discordDetailThreadId), @@ -1775,7 +1934,11 @@ export class DiscordCodexBridge { delegation: DiscordGatewayDelegation, options: { includeTaskResult: boolean }, ): Promise { - const config = this.#gatewayWorkbenchConfig(); + const surface = this.#gatewaySurfaceForDelegation(delegation); + if (surface) { + delegation.surfaceKey ??= surface.key; + } + const config = this.#gatewayWorkbenchConfig(surface); if (!config) { return { enabled: false }; } @@ -1810,23 +1973,24 @@ export class DiscordCodexBridge { async #materializeWorkspaceThread( codexThreadId: string, - input: { author: { id: string } }, + input: { author: { id: string }; surface?: GatewaySurface }, ): Promise { - const config = this.#gatewayWorkbenchConfig(); - if (!config) { - throw new Error("Gateway workbench is not enabled."); - } + const delegation = this.#delegationForThread(codexThreadId); + const observed = this.#observedThreadForThread(codexThreadId); + let surface = input.surface ?? + (delegation ? this.#gatewaySurfaceForDelegation(delegation) : undefined) ?? + (observed ? this.#gatewaySurfaceForObserved(observed) : undefined); + let config = this.#gatewayWorkbenchConfig(surface); const existing = this.#requireState().sessions.find((session) => session.codexThreadId === codexThreadId && - session.parentChannelId === config.taskThreadsChannelId + (!config || session.parentChannelId === config.taskThreadsChannelId) ); if (existing) { + existing.surfaceKey ??= this.#gatewaySurfaceForSession(existing)?.key; this.#registerRunner(existing).start(); return existing; } - const delegation = this.#delegationForThread(codexThreadId); - const observed = this.#observedThreadForThread(codexThreadId); const resumed = await this.client.resumeThread( this.#threadResumeParams(codexThreadId, delegation?.cwd ?? observed?.cwd), ); @@ -1834,6 +1998,28 @@ export class DiscordCodexBridge { const cwd = resumeResponseCwd(resumed) ?? thread?.cwd ?? delegation?.cwd ?? observed?.cwd ?? this.config.cwd; + surface = surface ?? this.#gatewaySurfaceForCwd(cwd); + if (surface) { + if (delegation) { + delegation.surfaceKey ??= surface.key; + } + if (observed) { + observed.surfaceKey ??= surface.key; + } + } + config = this.#gatewayWorkbenchConfig(surface); + if (!config) { + throw new Error("Gateway 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)}`); @@ -1854,6 +2040,7 @@ export class DiscordCodexBridge { ownerUserId: input.author.id, cwd, mode: "workspace", + surfaceKey: surface?.key, }; this.#requireState().sessions.push(session); this.#registerRunner(session).start(); @@ -1877,12 +2064,20 @@ export class DiscordCodexBridge { } async #reconcileGatewayWorkbench(): Promise { - const config = this.#gatewayWorkbenchConfig(); - if (!config) { + if ( + this.#gatewaySurfaces().every((surface) => + !this.#gatewayWorkbenchConfig(surface) + ) + ) { return; } for (const cwd of await this.#discoverGatewayWorkspaceCwds()) { try { + const surface = this.#gatewaySurfaceForCwd(cwd); + const config = this.#gatewayWorkbenchConfig(surface); + if (!config) { + continue; + } const workspace = await this.#ensureWorkspaceSurfaceForCwd(cwd, config); await this.#updateWorkspaceSurface(workspace); } catch (error) { @@ -1942,7 +2137,7 @@ export class DiscordCodexBridge { async #ensureWorkspaceSurface( delegation: DiscordGatewayDelegation, - config: { workspaceForumChannelId: string; taskThreadsChannelId: string }, + config: GatewayWorkbenchConfig, ): Promise { const workspace = await this.#ensureWorkspaceSurfaceForCwd( workspaceCwdForPath(delegation.cwd ?? this.config.cwd, this.config.cwd), @@ -1950,13 +2145,14 @@ export class DiscordCodexBridge { [delegation], ); delegation.workspaceKey = workspace.key; + delegation.surfaceKey = workspace.surfaceKey; delegation.discordWorkspaceThreadId = workspace.discordThreadId; return workspace; } async #ensureWorkspaceSurfaceForCwd( cwd: string, - config: { workspaceForumChannelId: string; taskThreadsChannelId: string }, + config: GatewayWorkbenchConfig, delegations: DiscordGatewayDelegation[] = [], ): Promise { if (!this.transport.createForumPost) { @@ -1967,7 +2163,8 @@ export class DiscordCodexBridge { const now = this.#now().toISOString(); const delegationIds = delegations.map((delegation) => delegation.id); let workspace = this.#gatewayWorkspaces().find((candidate) => - candidate.key === key + candidate.key === key && + (candidate.surfaceKey ?? config.surfaceKey) === config.surfaceKey ); if (!workspace) { const title = workspaceTitle(normalizedCwd); @@ -1976,6 +2173,7 @@ export class DiscordCodexBridge { truncateDiscordThreadName(title), workspaceDashboardText({ key, + surfaceKey: config.surfaceKey, cwd: normalizedCwd, title, discordThreadId: "pending", @@ -1987,6 +2185,7 @@ export class DiscordCodexBridge { ); workspace = { key, + surfaceKey: config.surfaceKey, cwd: normalizedCwd, title, discordThreadId: created.threadId, @@ -2005,10 +2204,14 @@ export class DiscordCodexBridge { 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; } @@ -2016,7 +2219,7 @@ export class DiscordCodexBridge { async #ensureDelegationTaskThread( delegation: DiscordGatewayDelegation, workspace: DiscordGatewayWorkspaceSurface, - config: { workspaceForumChannelId: string; taskThreadsChannelId: string }, + config: GatewayWorkbenchConfig, ): Promise { if (!delegation.discordTaskThreadId) { delegation.discordWorkspaceThreadId = workspace.discordThreadId; @@ -2029,7 +2232,9 @@ export class DiscordCodexBridge { ) : undefined; if (existingSession) { + existingSession.surfaceKey ??= config.surfaceKey; delegation.discordDetailThreadId ??= delegation.discordTaskThreadId; + delegation.surfaceKey ??= config.surfaceKey; delegation.discordWorkspaceThreadId = workspace.discordThreadId; this.#registerRunner(existingSession); return; @@ -2043,8 +2248,10 @@ export class DiscordCodexBridge { 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); @@ -2096,6 +2303,10 @@ export class DiscordCodexBridge { put(thread); } for (const delegation of this.#gatewayDelegations()) { + if (this.#gatewaySurfaceForDelegation(delegation)?.key !== + this.#gatewaySurfaceForWorkspace(workspace)?.key) { + continue; + } const delegationWorkspaceKey = delegation.workspaceKey ?? workspaceKey(workspaceCwdForPath(delegation.cwd, this.config.cwd)); if ( @@ -2114,6 +2325,10 @@ export class DiscordCodexBridge { }); } for (const observed of this.#gatewayObservedThreads()) { + if (this.#gatewaySurfaceForObserved(observed)?.key !== + this.#gatewaySurfaceForWorkspace(workspace)?.key) { + continue; + } const observedWorkspaceKey = observed.workspaceKey ?? workspaceKey(workspaceCwdForPath(observed.cwd, this.config.cwd)); if ( @@ -2130,6 +2345,7 @@ export class DiscordCodexBridge { updatedAt: Date.parse(observed.lastSeenAt) / 1000, discordThreadId: this.#workspaceDiscordThreadForCodexThread( observed.threadId, + this.#gatewaySurfaceForWorkspace(workspace), )?.discordThreadId, }); } @@ -2141,15 +2357,26 @@ export class DiscordCodexBridge { workspace: DiscordGatewayWorkspaceSurface, ): Promise { const byId = new Map(); + const surface = this.#gatewaySurfaceForWorkspace(workspace); for (const thread of await this.#listCodexThreadSummaries()) { if ( workspaceKey(workspaceCwdForPath(thread.cwd, this.config.cwd)) === - workspace.key + workspace.key && + this.#gatewaySurfaceForCwd(thread.cwd)?.key === surface?.key ) { - byId.set(thread.id, thread); + byId.set(thread.id, { + ...thread, + discordThreadId: this.#workspaceDiscordThreadForCodexThread( + thread.id, + surface, + )?.discordThreadId, + }); } } for (const delegation of this.#gatewayDelegations()) { + if (this.#gatewaySurfaceForDelegation(delegation)?.key !== surface?.key) { + continue; + } const delegationWorkspaceKey = delegation.workspaceKey ?? workspaceKey(workspaceCwdForPath(delegation.cwd, this.config.cwd)); if ( @@ -2168,6 +2395,9 @@ export class DiscordCodexBridge { }); } for (const observed of this.#gatewayObservedThreads()) { + if (this.#gatewaySurfaceForObserved(observed)?.key !== surface?.key) { + continue; + } const observedWorkspaceKey = observed.workspaceKey ?? workspaceKey(workspaceCwdForPath(observed.cwd, this.config.cwd)); if (observedWorkspaceKey !== workspace.key) { @@ -2182,6 +2412,7 @@ export class DiscordCodexBridge { updatedAt: Date.parse(observed.lastSeenAt) / 1000, discordThreadId: this.#workspaceDiscordThreadForCodexThread( observed.threadId, + surface, )?.discordThreadId, }; byId.set( @@ -2253,9 +2484,14 @@ export class DiscordCodexBridge { }; } - async #listActiveCodexThreadSummaries(): Promise { + async #listActiveCodexThreadSummaries( + surface: GatewaySurface | undefined = this.#primaryGatewaySurface(), + ): Promise { const byId = new Map(); const put = (summary: WorkspaceThreadSummary) => { + if (this.#gatewaySurfaceForCwd(summary.cwd)?.key !== surface?.key) { + return; + } const existing = byId.get(summary.id); byId.set(summary.id, { ...existing, @@ -2266,7 +2502,7 @@ export class DiscordCodexBridge { updatedAt: Math.max(existing?.updatedAt ?? 0, summary.updatedAt), discordThreadId: existing?.discordThreadId ?? summary.discordThreadId ?? - this.#discordChannelForCodexThread(summary.id), + this.#discordChannelForCodexThread(summary.id, surface), }); }; @@ -2274,8 +2510,7 @@ export class DiscordCodexBridge { if (thread.status === "active") { put({ ...thread, - discordThreadId: this.#discordChannelForCodexThread(thread.id) ?? - thread.discordThreadId, + discordThreadId: this.#discordChannelForCodexThread(thread.id, surface), }); } } @@ -2305,7 +2540,10 @@ export class DiscordCodexBridge { cwd: delegation.cwd ?? this.config.cwd ?? process.cwd(), status: delegation.lastStatus ?? delegation.status, updatedAt: Date.parse(delegation.updatedAt) / 1000, - discordThreadId: this.#discordChannelForCodexThread(delegation.codexThreadId), + discordThreadId: this.#discordChannelForCodexThread( + delegation.codexThreadId, + surface, + ), }); } @@ -2319,7 +2557,7 @@ export class DiscordCodexBridge { cwd: observed.cwd ?? this.config.cwd ?? process.cwd(), status: observedThreadStatusText(observed), updatedAt: Date.parse(observed.lastSeenAt) / 1000, - discordThreadId: this.#discordChannelForCodexThread(observed.threadId), + discordThreadId: this.#discordChannelForCodexThread(observed.threadId, surface), }); } @@ -2329,14 +2567,16 @@ export class DiscordCodexBridge { #listOpenWorkspaceThreads( workspace: DiscordGatewayWorkspaceSurface, ): WorkspaceThreadSummary[] { - const workbench = this.#gatewayWorkbenchConfig(); + const surface = this.#gatewaySurfaceForWorkspace(workspace); + const workbench = this.#gatewayWorkbenchConfig(surface); if (!workbench) { return []; } const sessions = this.#requireState().sessions.filter((session) => session.parentChannelId === workbench.taskThreadsChannelId && workspaceKey(workspaceCwdForPath(session.cwd, this.config.cwd)) === - workspace.key + workspace.key && + this.#gatewaySurfaceForSession(session)?.key === surface?.key ); return sessions.map((session) => ({ id: session.codexThreadId, @@ -2392,25 +2632,33 @@ export class DiscordCodexBridge { #workspaceDiscordThreadForCodexThread( codexThreadId: string, + surface?: GatewaySurface, ): DiscordBridgeSession | undefined { - const workbench = this.#gatewayWorkbenchConfig(); + const workbench = this.#gatewayWorkbenchConfig(surface); return this.#requireState().sessions.find((session) => session.codexThreadId === codexThreadId && session.parentChannelId === workbench?.taskThreadsChannelId ); } - #discordChannelForCodexThread(codexThreadId: string): string | undefined { + #discordChannelForCodexThread( + codexThreadId: string, + surface: GatewaySurface | undefined = this.#primaryGatewaySurface(), + ): string | undefined { if (this.#isGatewayMainThread(codexThreadId)) { - return this.config.gateway?.homeChannelId; + return surface?.homeChannelId ?? this.config.gateway?.homeChannelId; } const session = this.#requireState().sessions.find((candidate) => - candidate.codexThreadId === codexThreadId + candidate.codexThreadId === codexThreadId && + this.#gatewaySurfaceForSession(candidate)?.key === surface?.key ); const delegation = this.#delegationForThread(codexThreadId); + const delegationChannel = delegation && + this.#gatewaySurfaceForDelegation(delegation)?.key === surface?.key + ? delegation.discordTaskThreadId ?? delegation.discordDetailThreadId + : undefined; return session?.discordThreadId ?? - delegation?.discordTaskThreadId ?? - delegation?.discordDetailThreadId; + delegationChannel; } #workspaceForChannel(channelId: string): DiscordGatewayWorkspaceSurface | undefined { @@ -2428,7 +2676,11 @@ export class DiscordCodexBridge { return undefined; } const key = workspaceKey(workspaceCwdForPath(session.cwd, this.config.cwd)); - return workspaces.find((workspace) => workspace.key === key); + const surface = this.#gatewaySurfaceForSession(session); + return workspaces.find((workspace) => + workspace.key === key && + this.#gatewaySurfaceForWorkspace(workspace)?.key === surface?.key + ); } #sessionForDiscordThread(channelId: string): DiscordBridgeSession | undefined { @@ -2453,8 +2705,10 @@ export class DiscordCodexBridge { return existing; } const cwd = workspaceCwdForPath(session.cwd, this.config.cwd); + const surface = this.#gatewaySurfaceForSession(session); return { key: workspaceKey(cwd), + surfaceKey: surface?.key, cwd, title: workspaceTitle(cwd), discordThreadId: session.parentChannelId, @@ -2663,11 +2917,13 @@ export class DiscordCodexBridge { } const cwd = event.cwd ?? observed.cwd; + const surface = this.#gatewaySurfaceForCwd(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; @@ -2688,7 +2944,7 @@ export class DiscordCodexBridge { observed.lastSeenAt = seenAt; observed.updatedAt = seenAt; - const config = this.#gatewayWorkbenchConfig(); + const config = this.#gatewayWorkbenchConfig(surface); if (config && cwd) { const workspace = await this.#ensureWorkspaceSurfaceForCwd( workspaceCwdForPath(cwd, this.config.cwd), @@ -2765,7 +3021,9 @@ export class DiscordCodexBridge { } async #mirrorDelegationResult(delegation: DiscordGatewayDelegation): Promise { - const homeChannelId = this.config.gateway?.homeChannelId; + const surface = this.#gatewaySurfaceForDelegation(delegation); + const homeChannelId = surface?.homeChannelId ?? + (this.config.gateway?.surfaces?.length ? undefined : this.config.gateway?.homeChannelId); if (!homeChannelId || delegation.mirroredAt) { return; } @@ -2775,7 +3033,7 @@ export class DiscordCodexBridge { ); await this.transport.sendMessage( homeChannelId, - this.#gatewayWorkbenchConfig() && hasWorkbenchLinks + this.#gatewayWorkbenchConfig(surface) && hasWorkbenchLinks ? compactDelegationResultText(delegation) : delegationResultText(delegation), ); @@ -2937,9 +3195,10 @@ export class DiscordCodexBridge { completedTurnId?: string; } = {}, ): boolean { + const isGateway = session.mode === "gateway"; const hasActiveTurn = state.activeTurns.some( (active) => - active.discordThreadId === session.discordThreadId && + (isGateway || active.discordThreadId === session.discordThreadId) && active.codexThreadId === session.codexThreadId && !( active.codexThreadId === options.completedThreadId && @@ -2951,7 +3210,7 @@ export class DiscordCodexBridge { } return state.queue.some( (item) => - item.discordThreadId === session.discordThreadId && + (isGateway || item.discordThreadId === session.discordThreadId) && item.codexThreadId === session.codexThreadId && item.status !== "failed" && !( @@ -2962,11 +3221,10 @@ export class DiscordCodexBridge { } #isAllowedChannel(channelId: string): boolean { - const workbench = this.#gatewayWorkbenchConfig(); if ( - channelId === this.config.gateway?.homeChannelId || - channelId === workbench?.workspaceForumChannelId || - channelId === workbench?.taskThreadsChannelId + this.#gatewaySurfaceForHomeChannel(channelId) || + this.#gatewaySurfaceForWorkspaceForumChannel(channelId) || + this.#gatewaySurfaceForTaskThreadsChannel(channelId) ) { return true; } @@ -2986,6 +3244,9 @@ export class DiscordCodexBridge { const session = this.#requireState().sessions.find( (candidate) => candidate.discordThreadId === channelId, ); + const workbench = this.#gatewayWorkbenchConfig( + this.#gatewaySurfaceForSession(session), + ); return Boolean( session && (this.config.allowedChannelIds.has(session.parentChannelId) || @@ -2995,12 +3256,13 @@ export class DiscordCodexBridge { } #commandRegistrationChannelIds(): string[] { - const gateway = this.config.gateway; return uniqueStringList([ ...this.config.allowedChannelIds, - gateway?.homeChannelId ?? "", - gateway?.workspaceForumChannelId ?? "", - gateway?.taskThreadsChannelId ?? "", + ...this.#gatewaySurfaces().flatMap((surface) => [ + surface.homeChannelId, + surface.workspaceForumChannelId ?? "", + surface.taskThreadsChannelId ?? "", + ]), ]); } diff --git a/apps/discord-bridge/src/config.ts b/apps/discord-bridge/src/config.ts index ec2713b..c7d4b71 100644 --- a/apps/discord-bridge/src/config.ts +++ b/apps/discord-bridge/src/config.ts @@ -1,3 +1,4 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -10,6 +11,7 @@ import type { import type { DiscordBridgeConfig, DiscordConsoleOutputMode, + DiscordGatewaySurfaceConfig, DiscordProgressMode, } from "./types.ts"; import type { DiscordBridgeLogLevelSetting } from "./logger.ts"; @@ -65,6 +67,7 @@ const sandboxValues = new Set([ "workspace-write", "danger-full-access", ]); +const defaultGatewaySurfaceKey = "default"; export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfig { const args = parseFlags(argv); @@ -112,6 +115,13 @@ export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfi 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", @@ -125,18 +135,12 @@ export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfi env.CODEX_DISCORD_ALLOWED_CHANNEL_IDS, ), statePath, - gateway: gatewayConfig(args, env), + gateway: gatewayConfig(args, env, cwd), flowBackendUrl: stringFlag(args, "flow-backend-url") ?? env.CODEX_FLOW_BACKEND_URL ?? env.CODEX_GATEWAY_BACKEND_URL, - cwd: resolveHomeDir( - stringFlag(args, "dir") ?? - stringFlag(args, "positional-dir") ?? - env.CODEX_DISCORD_DIR ?? - stringFlag(args, "cwd") ?? - env.CODEX_DISCORD_CWD, - ), + cwd, model: stringFlag(args, "model") ?? env.CODEX_DISCORD_MODEL, modelProvider: stringFlag(args, "model-provider") ?? @@ -234,6 +238,16 @@ function csvSet(value: string | undefined): Set { ); } +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) { @@ -279,27 +293,40 @@ function optionalProgressMode(value: string | undefined): DiscordProgressMode | function gatewayConfig( flags: Map, env: NodeJS.ProcessEnv, + workspaceRoot: string | undefined, ): DiscordBridgeConfig["gateway"] { - const homeChannelId = + const workspaceSurfaces = gatewaySurfacesConfig(workspaceRoot); + const configuredHomeChannelId = stringFlag(flags, "home-channel-id") ?? stringFlag(flags, "gateway-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, "gateway-main-thread-id") ?? env.CODEX_DISCORD_MAIN_THREAD_ID ?? env.CODEX_DISCORD_GATEWAY_MAIN_THREAD_ID; - const workspaceForumChannelId = + const configuredWorkspaceForumChannelId = stringFlag(flags, "workspace-forum-channel-id") ?? stringFlag(flags, "gateway-workspace-forum-channel-id") ?? env.CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID ?? env.CODEX_DISCORD_GATEWAY_WORKSPACE_FORUM_CHANNEL_ID; - const taskThreadsChannelId = + const workspaceForumChannelId = configuredWorkspaceForumChannelId ?? + (useWorkspacePrimaryDefaults + ? workspaceSurfaces[0]?.workspaceForumChannelId + : undefined); + const configuredTaskThreadsChannelId = stringFlag(flags, "task-threads-channel-id") ?? stringFlag(flags, "gateway-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 gateway main thread without a gateway home channel."); @@ -325,14 +352,246 @@ function gatewayConfig( "Discord workbench channels must be separate from the gateway home channel and each other.", ); } + const defaultSurface = { + key: defaultGatewaySurfaceKey, + homeChannelId, + workspaceForumChannelId, + taskThreadsChannelId, + }; + const surfaces = workspaceSurfaces.length > 0 + ? mergeGatewaySurfaces( + configuredHomeChannelId + ? [defaultSurface, ...workspaceSurfaces] + : workspaceSurfaces, + ) + : []; return { homeChannelId, mainThreadId, workspaceForumChannelId, taskThreadsChannelId, + surfaces: surfaces.length > 0 ? surfaces : undefined, }; } +function gatewaySurfacesConfig( + workspaceRoot: string | undefined, +): DiscordGatewaySurfaceConfig[] { + if (!workspaceRoot) { + return []; + } + const workspaceCwds = discoverWorkspaceConfigCwds(workspaceRoot); + if (workspaceCwds.length === 0) { + return []; + } + const surfaces: DiscordGatewaySurfaceConfig[] = []; + for (const workspaceCwd of workspaceCwds) { + const surface = workspaceGatewaySurfaceConfig(workspaceCwd); + if (surface) { + surfaces.push(surface); + } + } + return mergeGatewaySurfaces(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 workspaceGatewaySurfaceConfig( + workspaceCwd: string, +): DiscordGatewaySurfaceConfig | undefined { + const configPath = path.join(workspaceCwd, ".codex", "workspace.toml"); + if (!existsSync(configPath)) { + return undefined; + } + let parsed: unknown; + try { + parsed = Bun.TOML.parse(readFileSync(configPath, "utf8")); + } catch (error) { + throw new Error( + `Invalid workspace config TOML at ${configPath}: ${errorMessage(error)}`, + ); + } + const surfacesInput = gatewaySurfaceEntries(parsed); + if (surfacesInput === undefined) { + return undefined; + } + if (!Array.isArray(surfacesInput)) { + throw new Error( + `workspace.toml discord.gateway.surfaces must be an array: ${configPath}`, + ); + } + if (surfacesInput.length === 0) { + return undefined; + } + if (surfacesInput.length > 1) { + throw new Error( + `workspace.toml discord.gateway.surfaces must contain one surface: ${configPath}`, + ); + } + return parseGatewaySurface(surfacesInput[0], 0, workspaceCwd); +} + +function gatewaySurfaceEntries(input: unknown): unknown { + const parsed = record(input); + if (parsed.discord === undefined) { + return undefined; + } + const discord = record(parsed.discord); + if (discord.gateway === undefined) { + return undefined; + } + const gateway = record(discord.gateway); + return gateway.surfaces; +} + +function parseGatewaySurface( + input: unknown, + index: number, + workspaceCwd: string, +): DiscordGatewaySurfaceConfig { + 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(`Gateway surface at index ${index} is missing key.`); + } + if (!homeChannelId) { + throw new Error(`Gateway surface ${key} is missing homeChannelId.`); + } + if (Boolean(workspaceForumChannelId) !== Boolean(taskThreadsChannelId)) { + throw new Error( + `Gateway surface ${key} requires both workspaceForumChannelId and taskThreadsChannelId.`, + ); + } + if ( + workspaceForumChannelId && + taskThreadsChannelId && + (homeChannelId === workspaceForumChannelId || + homeChannelId === taskThreadsChannelId || + workspaceForumChannelId === taskThreadsChannelId) + ) { + throw new Error(`Gateway surface ${key} channels must be distinct.`); + } + return { + key, + homeChannelId, + workspaceForumChannelId, + taskThreadsChannelId, + workspaceCwds: [path.normalize(workspaceCwd)], + }; +} + +function mergeGatewaySurfaces( + surfaces: DiscordGatewaySurfaceConfig[], +): DiscordGatewaySurfaceConfig[] { + 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( + `Gateway 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()]; + validateGatewaySurfaces(merged); + return merged; +} + +function validateGatewaySurfaces(surfaces: DiscordGatewaySurfaceConfig[]): 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( + `Gateway surface channel is configured more than once: ${channelId}`, + ); + } + channelIds.add(channelId); + } + } + if (catchAllSurfaces > 1) { + throw new Error("Only one gateway surface may omit workspaceCwds."); + } +} + +function uniqueStringList(values: string[]): string[] { + return [...new Set(values)]; +} + function optionalConsoleOutput( value: string | undefined, ): DiscordConsoleOutputMode | undefined { @@ -439,3 +698,7 @@ function resolveHomeDir(value: string | undefined): string | undefined { } 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/runner.ts b/apps/discord-bridge/src/runner.ts index 08a2e83..28de8e8 100644 --- a/apps/discord-bridge/src/runner.ts +++ b/apps/discord-bridge/src/runner.ts @@ -153,7 +153,7 @@ export class DiscordThreadRunner { id: `${message.messageId}-${Date.now()}`, status: "pending", discordMessageId: message.messageId, - discordThreadId: this.session.discordThreadId, + discordThreadId: message.channelId, codexThreadId: this.session.codexThreadId, authorId: message.author.id, authorName: message.author.name, @@ -198,7 +198,7 @@ export class DiscordThreadRunner { id: `${message.messageId}-steer`, status: "pending", discordMessageId: message.messageId, - discordThreadId: this.session.discordThreadId, + discordThreadId: message.channelId, codexThreadId: this.session.codexThreadId, authorId: message.author.id, authorName: message.author.name, @@ -273,6 +273,7 @@ export class DiscordThreadRunner { origin: "discord", queueItemId: item.id, startedAt: turnStartedAt(turn), + discordThreadId: item.discordThreadId, }); await this.#startTypingHeartbeat(active); this.#scheduleActiveTurnReconcile(active); @@ -386,6 +387,7 @@ export class DiscordThreadRunner { origin: "discord", queueItemId: item.id, startedAt: turnStartedAt(started.turn), + discordThreadId: item.discordThreadId, }); await this.#startTypingHeartbeat(active); this.#scheduleActiveTurnReconcile(active); @@ -595,6 +597,7 @@ export class DiscordThreadRunner { 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), ""); @@ -996,7 +999,8 @@ export class DiscordThreadRunner { this.#state().deliveries .filter( (delivery) => - delivery.discordThreadId === this.session.discordThreadId && + (this.session.mode === "gateway" || + delivery.discordThreadId === this.session.discordThreadId) && delivery.codexThreadId === this.session.codexThreadId && delivery.kind === "final" && Boolean(delivery.turnId), @@ -1043,6 +1047,7 @@ export class DiscordThreadRunner { origin: "discord", queueItemId: item.id, startedAt: turnStartedAt(completedTurn), + discordThreadId: item.discordThreadId, }) : this.#upsertActiveTurn({ turnId, @@ -1129,6 +1134,7 @@ export class DiscordThreadRunner { 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}.`); @@ -1559,7 +1565,8 @@ export class DiscordThreadRunner { #hasDelivery(turnId: string, kind: DiscordBridgeDelivery["kind"]): boolean { return this.#state().deliveries.some( (delivery) => - delivery.discordThreadId === this.session.discordThreadId && + (this.session.mode === "gateway" || + delivery.discordThreadId === this.session.discordThreadId) && delivery.codexThreadId === this.session.codexThreadId && delivery.turnId === turnId && delivery.kind === kind, @@ -1596,7 +1603,8 @@ export class DiscordThreadRunner { #sessionActiveTurns(): DiscordBridgeActiveTurn[] { return this.#state().activeTurns.filter( (active) => - active.discordThreadId === this.session.discordThreadId && + (this.session.mode === "gateway" || + active.discordThreadId === this.session.discordThreadId) && active.codexThreadId === this.session.codexThreadId, ); } @@ -1606,12 +1614,14 @@ export class DiscordThreadRunner { 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) => - active.discordThreadId !== this.session.discordThreadId || + (this.session.mode !== "gateway" && + active.discordThreadId !== this.session.discordThreadId) || active.codexThreadId !== this.session.codexThreadId || active.turnId === input.turnId, ); @@ -1620,12 +1630,13 @@ export class DiscordThreadRunner { 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: this.session.discordThreadId, + discordThreadId: input.discordThreadId ?? this.session.discordThreadId, codexThreadId: this.session.codexThreadId, origin: input.origin, queueItemId: input.queueItemId, @@ -1640,7 +1651,8 @@ export class DiscordThreadRunner { const state = this.#state(); state.activeTurns = state.activeTurns.filter( (active) => - active.discordThreadId !== this.session.discordThreadId || + (this.session.mode !== "gateway" && + active.discordThreadId !== this.session.discordThreadId) || active.codexThreadId !== this.session.codexThreadId || active.turnId !== turnId, ); @@ -1665,7 +1677,8 @@ export class DiscordThreadRunner { #sessionQueueItems(): DiscordBridgeQueueItem[] { return this.#state().queue.filter( (item) => - item.discordThreadId === this.session.discordThreadId && + (this.session.mode === "gateway" || + item.discordThreadId === this.session.discordThreadId) && item.codexThreadId === this.session.codexThreadId, ); } @@ -1684,7 +1697,7 @@ export class DiscordThreadRunner { } #formatPrompt(item: DiscordBridgeQueueItem): string { - return formatDiscordPrompt(item, this.session); + return formatDiscordPrompt(item, this.session, this.#context.config); } #emitConsoleMessage( @@ -2064,20 +2077,25 @@ function truncateOneLine(value: string, maxLength: number): string { function formatDiscordPrompt( item: DiscordBridgeQueueItem, session: DiscordBridgeSession, + config: DiscordBridgeConfig, ): string { if (session.mode === "gateway") { + const surface = config.gateway?.surfaces?.find((candidate) => + candidate.homeChannelId === item.discordThreadId + ); return [ "[discord-gateway]", - "Role: You are the main Codex operator thread for a single Discord home channel.", + "Role: You are the main Codex operator thread for a configured Discord gateway surface.", "Intent: Treat this as a gateway 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}`, `Gateway cwd: ${session.cwd ?? "default"}`, "", item.content, - ].join("\n"); + ].filter((line): line is string => line !== undefined).join("\n"); } return [ "[discord]", diff --git a/apps/discord-bridge/src/state.ts b/apps/discord-bridge/src/state.ts index dafc212..e0e1308 100644 --- a/apps/discord-bridge/src/state.ts +++ b/apps/discord-bridge/src/state.ts @@ -187,6 +187,7 @@ function parseGatewayDelegation(value: unknown): DiscordGatewayDelegation { 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), @@ -212,6 +213,7 @@ function parseGatewayWorkspace(value: unknown): DiscordGatewayWorkspaceSurface { } return { key: requiredString(value.key, "gateway.workspaces.key"), + surfaceKey: optionalString(value.surfaceKey), cwd: requiredString(value.cwd, "gateway.workspaces.cwd"), title: requiredString(value.title, "gateway.workspaces.title"), discordThreadId: requiredString( @@ -262,6 +264,7 @@ function parseGatewayObservedThread(value: unknown): DiscordGatewayObservedThrea 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), @@ -339,6 +342,7 @@ function parseSession(value: unknown): DiscordBridgeSession { 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"), diff --git a/apps/discord-bridge/src/types.ts b/apps/discord-bridge/src/types.ts index f13b9df..e6d66a8 100644 --- a/apps/discord-bridge/src/types.ts +++ b/apps/discord-bridge/src/types.ts @@ -38,6 +38,15 @@ export type DiscordGatewayConfig = { mainThreadId?: string; workspaceForumChannelId?: string; taskThreadsChannelId?: string; + surfaces?: DiscordGatewaySurfaceConfig[]; +}; + +export type DiscordGatewaySurfaceConfig = { + key: string; + homeChannelId: string; + workspaceForumChannelId?: string; + taskThreadsChannelId?: string; + workspaceCwds?: string[]; }; export type DiscordAuthor = { @@ -265,6 +274,7 @@ export type DiscordGatewayDelegation = { status: "active" | "idle" | "failed" | "complete" | "reported"; cwd?: string; workspaceKey?: string; + surfaceKey?: string; groupId?: string; returnMode?: DiscordGatewayDelegationReturnMode; discordDetailThreadId?: string; @@ -285,6 +295,7 @@ export type DiscordGatewayDelegation = { export type DiscordGatewayWorkspaceSurface = { key: string; + surfaceKey?: string; cwd: string; title: string; discordThreadId: string; @@ -350,6 +361,7 @@ export type DiscordGatewayObservedThread = { status: DiscordGatewayObservedThreadStatus; cwd?: string; workspaceKey?: string; + surfaceKey?: string; model?: string; transcriptPath?: string; lastTurnId?: string; @@ -371,6 +383,7 @@ export type DiscordBridgeSession = { discordThreadId: string; parentChannelId: string; guildId?: string; + surfaceKey?: string; sourceMessageId?: string; codexThreadId: string; title: string; diff --git a/apps/discord-bridge/test/bridge.test.ts b/apps/discord-bridge/test/bridge.test.ts index 28edc50..d483852 100644 --- a/apps/discord-bridge/test/bridge.test.ts +++ b/apps/discord-bridge/test/bridge.test.ts @@ -1443,6 +1443,256 @@ describe("DiscordCodexBridge", () => { } }); + test("multi-guild gateway 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, + gateway: { + 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().gateway?.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().gateway?.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 gateway 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 gateway answer." + ) + ); + expect(transport.messages.some((message) => + message.channelId === "home-default" && + message.text === "Crypto gateway 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 }); diff --git a/apps/discord-bridge/test/config.test.ts b/apps/discord-bridge/test/config.test.ts index 12e833b..8ac2b4c 100644 --- a/apps/discord-bridge/test/config.test.ts +++ b/apps/discord-bridge/test/config.test.ts @@ -1,3 +1,4 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, test } from "bun:test"; @@ -236,6 +237,149 @@ describe("parseConfig", () => { } }); + test("parses workspace-owned gateway surfaces and keeps env defaults as fallback", () => { + const root = workspaceRoot(); + writeWorkspaceToml(root, "crypto-workspace", ` +[[discord.gateway.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.gateway.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.gateway).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 gateway surfaces", () => { + const multiple = workspaceRoot(); + writeWorkspaceToml(multiple, "crypto-workspace", ` +[[discord.gateway.surfaces]] +key = "default" +home_channel_id = "home-a" + +[[discord.gateway.surfaces]] +key = "other" +home_channel_id = "home-b" +`); + try { + expect(() => parseConfig(baseArgsForRoot(multiple), {})).toThrow( + "workspace.toml discord.gateway.surfaces must contain one surface", + ); + } finally { + rmSync(multiple, { recursive: true, force: true }); + } + + const duplicate = workspaceRoot(); + writeWorkspaceToml(duplicate, "crypto-workspace", ` +[[discord.gateway.surfaces]] +key = "default" +home_channel_id = "home-a" +`); + writeWorkspaceToml(duplicate, "research-workspace", ` +[[discord.gateway.surfaces]] +key = "default" +home_channel_id = "home-b" +`); + try { + expect(() => parseConfig(baseArgsForRoot(duplicate), {})).toThrow( + "Gateway surface key default is configured with different channels.", + ); + } finally { + rmSync(duplicate, { recursive: true, force: true }); + } + + const channelCollision = workspaceRoot(); + writeWorkspaceToml(channelCollision, "crypto-workspace", ` +[[discord.gateway.surfaces]] +key = "crypto" +home_channel_id = "home-a" +`); + writeWorkspaceToml(channelCollision, "alpha-workspace", ` +[[discord.gateway.surfaces]] +key = "alpha" +home_channel_id = "home-a" +`); + try { + expect(() => parseConfig(baseArgsForRoot(channelCollision), {})).toThrow( + "Gateway surface channel is configured more than once: home-a", + ); + } finally { + rmSync(channelCollision, { recursive: true, force: true }); + } + }); + + test("ignores workspace.toml without gateway 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.gateway).toBeUndefined(); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("rejects gateway main thread without home channel", () => { expect(() => parseConfig( @@ -330,3 +474,31 @@ describe("parseConfig", () => { ).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/state.test.ts b/apps/discord-bridge/test/state.test.ts index b9808f8..22faefc 100644 --- a/apps/discord-bridge/test/state.test.ts +++ b/apps/discord-bridge/test/state.test.ts @@ -28,6 +28,7 @@ describe("JsonFileStateStore", () => { status: "active", cwd: "/workspace/patchbay", workspaceKey: "workspace-patchbay", + surfaceKey: "org-a", discordDetailThreadId: "discord-detail-thread", discordTaskThreadId: "discord-task-thread", discordWorkspaceThreadId: "discord-workspace-thread", @@ -40,6 +41,7 @@ describe("JsonFileStateStore", () => { workspaces: [ { key: "workspace-patchbay", + surfaceKey: "org-a", cwd: "/workspace/patchbay", title: "patchbay", discordThreadId: "discord-workspace-thread", @@ -56,6 +58,7 @@ describe("JsonFileStateStore", () => { status: "waiting", cwd: "/workspace/patchbay", workspaceKey: "workspace-patchbay", + surfaceKey: "org-a", model: "gpt-test", transcriptPath: "/tmp/observed.jsonl", lastTurnId: "turn-observed", @@ -89,6 +92,7 @@ describe("JsonFileStateStore", () => { { discordThreadId: "discord-thread-1", parentChannelId: "parent-channel", + surfaceKey: "org-a", sourceMessageId: "message-start-1", codexThreadId: "codex-thread-1", title: "Granted thread", @@ -147,6 +151,7 @@ describe("JsonFileStateStore", () => { status: "active", cwd: "/workspace/patchbay", workspaceKey: "workspace-patchbay", + surfaceKey: "org-a", discordDetailThreadId: "discord-detail-thread", discordTaskThreadId: "discord-task-thread", discordWorkspaceThreadId: "discord-workspace-thread", @@ -159,6 +164,7 @@ describe("JsonFileStateStore", () => { workspaces: [ { key: "workspace-patchbay", + surfaceKey: "org-a", cwd: "/workspace/patchbay", title: "patchbay", discordThreadId: "discord-workspace-thread", @@ -175,6 +181,7 @@ describe("JsonFileStateStore", () => { status: "waiting", cwd: "/workspace/patchbay", workspaceKey: "workspace-patchbay", + surfaceKey: "org-a", model: "gpt-test", transcriptPath: "/tmp/observed.jsonl", lastTurnId: "turn-observed", @@ -208,6 +215,7 @@ describe("JsonFileStateStore", () => { 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",