discord workspace.toml

This commit is contained in:
matamune 2026-05-15 19:09:03 +00:00
parent 9dd84d82c4
commit 21b85703da
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
9 changed files with 1123 additions and 103 deletions

View file

@ -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

View file

@ -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<unknown> {
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<DiscordBridgeSession> {
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<void> {
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<DiscordGatewayWorkspaceSurface> {
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<DiscordGatewayWorkspaceSurface> {
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<void> {
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<WorkspaceThreadSummary[]> {
const byId = new Map<string, WorkspaceThreadSummary>();
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<WorkspaceThreadSummary[]> {
async #listActiveCodexThreadSummaries(
surface: GatewaySurface | undefined = this.#primaryGatewaySurface(),
): Promise<WorkspaceThreadSummary[]> {
const byId = new Map<string, WorkspaceThreadSummary>();
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<void> {
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 ?? "",
]),
]);
}

View file

@ -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<v2.SandboxMode>([
"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<string> {
);
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function booleanFlag(flags: Map<string, string | boolean>, name: string): boolean {
const value = flags.get(name);
if (value === true) {
@ -279,27 +293,40 @@ function optionalProgressMode(value: string | undefined): DiscordProgressMode |
function gatewayConfig(
flags: Map<string, string | boolean>,
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<string, DiscordGatewaySurfaceConfig>();
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<string>();
let catchAllSurfaces = 0;
for (const surface of surfaces) {
if (!surface.workspaceCwds || surface.workspaceCwds.length === 0) {
catchAllSurfaces += 1;
}
for (const channelId of [
surface.homeChannelId,
surface.workspaceForumChannelId,
surface.taskThreadsChannelId,
]) {
if (!channelId) {
continue;
}
if (channelIds.has(channelId)) {
throw new Error(
`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);
}

View file

@ -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]",

View file

@ -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"),

View file

@ -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;

View file

@ -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 });

View file

@ -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,
];
}

View file

@ -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",