discord workspace.toml
This commit is contained in:
parent
9dd84d82c4
commit
21b85703da
9 changed files with 1123 additions and 103 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue