parent
08263e60ec
commit
5e4235daea
5 changed files with 872 additions and 5 deletions
|
|
@ -35,6 +35,10 @@ In the home channel:
|
|||
- `/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
|
||||
- `/goals` is available from workspace forum posts and opens an ephemeral goal
|
||||
management picker for that workspace
|
||||
- `/goals` inside an opened Codex Discord thread manages that specific thread's
|
||||
goal; use the slash options to set the objective/status/token budget or clear it
|
||||
|
||||
The prompt sent to the main thread uses `[discord-gateway]` framing so the model
|
||||
knows it is operating as the gateway over the codex-flows backend, not as a
|
||||
|
|
@ -108,6 +112,12 @@ When the workbench is enabled:
|
|||
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
|
||||
- `/goals` in workspace forum posts lists recent workspace thread goals and lets
|
||||
the command sender mark existing goals active, paused, or complete, clear
|
||||
them, or open the thread into Discord
|
||||
- `/goals` in an opened Discord task thread scopes CRUD to that Codex thread:
|
||||
no options reads the current goal, `objective`/`status`/`token_budget` create
|
||||
or update it, and `clear` removes it
|
||||
- repeated delegations in the same cwd reuse the same workspace post and update
|
||||
the workspace thread list
|
||||
- Stop lifecycle events update the workspace dashboard and any already-opened
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import type {
|
|||
DiscordBridgeTransport,
|
||||
DiscordClearInbound,
|
||||
DiscordClearWebhooksInbound,
|
||||
DiscordGoalsInbound,
|
||||
DiscordInbound,
|
||||
DiscordMessageInbound,
|
||||
DiscordReactionInbound,
|
||||
|
|
@ -84,6 +85,25 @@ type WorkspaceThreadPicker = {
|
|||
entries: WorkspaceThreadSummary[];
|
||||
};
|
||||
|
||||
type WorkspaceGoalSummary = WorkspaceThreadSummary & {
|
||||
goal?: v2.ThreadGoal | null;
|
||||
goalError?: string;
|
||||
};
|
||||
|
||||
type WorkspaceGoalPicker = {
|
||||
channelId: string;
|
||||
authorId: string;
|
||||
workspace: DiscordGatewayWorkspaceSurface;
|
||||
entries: WorkspaceGoalSummary[];
|
||||
};
|
||||
|
||||
type WorkspaceGoalActionPicker = {
|
||||
channelId: string;
|
||||
authorId: string;
|
||||
workspace: DiscordGatewayWorkspaceSurface;
|
||||
entry: WorkspaceGoalSummary;
|
||||
};
|
||||
|
||||
export class DiscordCodexBridge {
|
||||
readonly client: CodexBridgeClient;
|
||||
readonly transport: DiscordBridgeTransport;
|
||||
|
|
@ -103,6 +123,8 @@ export class DiscordCodexBridge {
|
|||
#transportStarted = false;
|
||||
#threadPickersByMessage = new Map<string, WorkspaceThreadPicker>();
|
||||
#threadPickersById = new Map<string, WorkspaceThreadPicker>();
|
||||
#goalPickersById = new Map<string, WorkspaceGoalPicker>();
|
||||
#goalActionPickersById = new Map<string, WorkspaceGoalActionPicker>();
|
||||
|
||||
constructor(options: {
|
||||
client: CodexBridgeClient;
|
||||
|
|
@ -265,6 +287,10 @@ export class DiscordCodexBridge {
|
|||
await this.#handleThreadsCommand(inbound);
|
||||
return;
|
||||
}
|
||||
if (inbound.kind === "goals") {
|
||||
await this.#handleGoalsCommand(inbound);
|
||||
return;
|
||||
}
|
||||
if (inbound.kind === "threadPicker") {
|
||||
await this.#handleThreadPickerSelection(inbound);
|
||||
return;
|
||||
|
|
@ -526,6 +552,119 @@ export class DiscordCodexBridge {
|
|||
}
|
||||
}
|
||||
|
||||
async #handleGoalsCommand(command: DiscordGoalsInbound): Promise<void> {
|
||||
if (!this.config.allowedUserIds.has(command.author.id)) {
|
||||
this.#debug("goals.ignored.user", {
|
||||
channelId: command.channelId,
|
||||
authorId: command.author.id,
|
||||
});
|
||||
await command.reply?.("Only globally allowed Discord users can manage goals.");
|
||||
return;
|
||||
}
|
||||
if (!this.#isAllowedChannel(command.channelId)) {
|
||||
this.#debug("goals.ignored.channel", { channelId: command.channelId });
|
||||
await command.reply?.("This Discord channel is not allowed for the bridge.");
|
||||
return;
|
||||
}
|
||||
const session = this.#sessionForDiscordThread(command.channelId);
|
||||
if (session) {
|
||||
await this.#handleThreadGoalsCommand(command, session);
|
||||
return;
|
||||
}
|
||||
const workspace = this.#workspaceForumForChannel(command.channelId);
|
||||
if (!workspace) {
|
||||
await command.reply?.(
|
||||
"Run `/goals` in a workspace forum post or opened Codex thread.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!command.replyPicker) {
|
||||
await command.reply?.(
|
||||
"This Discord transport cannot send ephemeral goal pickers.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const entries = await this.#listWorkspaceGoalSummaries(workspace);
|
||||
if (entries.length === 0) {
|
||||
await command.reply?.(`No Codex threads found for ${workspace.title}.`);
|
||||
return;
|
||||
}
|
||||
const pickerEntries = entries.slice(0, threadPickerReactions.length);
|
||||
const pickerId = `goals-${randomUUID()}`;
|
||||
this.#goalPickersById.set(pickerId, {
|
||||
channelId: command.channelId,
|
||||
authorId: command.author.id,
|
||||
workspace,
|
||||
entries: pickerEntries,
|
||||
});
|
||||
try {
|
||||
await command.replyPicker({
|
||||
pickerId,
|
||||
text: goalPickerText(workspace, pickerEntries, entries.length),
|
||||
options: pickerEntries.map((_, index) => ({
|
||||
id: String(index),
|
||||
label: String(index + 1),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
this.#goalPickersById.delete(pickerId);
|
||||
await command.reply?.(
|
||||
`Failed to send the goal picker: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #handleThreadGoalsCommand(
|
||||
command: DiscordGoalsInbound,
|
||||
session: DiscordBridgeSession,
|
||||
): Promise<void> {
|
||||
const hasMutation = hasGoalMutation(command);
|
||||
if (command.clear && hasMutation) {
|
||||
await command.reply?.("Use either `clear` or goal updates, not both.");
|
||||
return;
|
||||
}
|
||||
const workspace = this.#workspaceForGoalSession(session);
|
||||
const picker = {
|
||||
channelId: command.channelId,
|
||||
authorId: command.author.id,
|
||||
workspace,
|
||||
};
|
||||
if (command.clear) {
|
||||
try {
|
||||
await this.client.clearThreadGoal({ threadId: session.codexThreadId });
|
||||
await command.reply?.(`Cleared goal for ${session.title}.`);
|
||||
} catch (error) {
|
||||
await command.reply?.(
|
||||
`Failed to clear goal for ${session.title}: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hasMutation) {
|
||||
try {
|
||||
const response = await this.client.setThreadGoal({
|
||||
threadId: session.codexThreadId,
|
||||
objective: command.objective,
|
||||
status: command.goalStatus,
|
||||
tokenBudget: command.tokenBudget,
|
||||
});
|
||||
await this.#showGoalActionPicker(
|
||||
command,
|
||||
picker,
|
||||
this.#goalSummaryFromSession(session, { goal: response.goal }),
|
||||
{ prefix: command.objective ? "Saved goal." : "Updated goal." },
|
||||
);
|
||||
} catch (error) {
|
||||
await command.reply?.(
|
||||
`Failed to update goal for ${session.title}: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const entry = await this.#goalSummaryForSession(session);
|
||||
await this.#showGoalActionPicker(command, picker, entry);
|
||||
}
|
||||
|
||||
async #handleThreadPickerSelection(
|
||||
selection: DiscordThreadPickerInbound,
|
||||
): Promise<void> {
|
||||
|
|
@ -533,12 +672,29 @@ export class DiscordCodexBridge {
|
|||
return;
|
||||
}
|
||||
const picker = this.#threadPickersById.get(selection.pickerId);
|
||||
if (!picker) {
|
||||
await selection.update?.("This thread picker is no longer active.");
|
||||
if (picker) {
|
||||
await this.#handleWorkspaceThreadPickerSelection(selection, picker);
|
||||
return;
|
||||
}
|
||||
const goalPicker = this.#goalPickersById.get(selection.pickerId);
|
||||
if (goalPicker) {
|
||||
await this.#handleGoalPickerSelection(selection, goalPicker);
|
||||
return;
|
||||
}
|
||||
const goalActionPicker = this.#goalActionPickersById.get(selection.pickerId);
|
||||
if (goalActionPicker) {
|
||||
await this.#handleGoalActionSelection(selection, goalActionPicker);
|
||||
return;
|
||||
}
|
||||
await selection.update?.("This picker is no longer active.");
|
||||
}
|
||||
|
||||
async #handleWorkspaceThreadPickerSelection(
|
||||
selection: DiscordThreadPickerInbound,
|
||||
picker: WorkspaceThreadPicker,
|
||||
): Promise<void> {
|
||||
if (selection.author.id !== picker.authorId) {
|
||||
await selection.reply?.("Only the user who ran `/threads` can use this picker.");
|
||||
await selection.reply?.("Only the user who ran the command can use this picker.");
|
||||
return;
|
||||
}
|
||||
const index = Number.parseInt(selection.optionId, 10);
|
||||
|
|
@ -570,6 +726,130 @@ export class DiscordCodexBridge {
|
|||
}
|
||||
}
|
||||
|
||||
async #handleGoalPickerSelection(
|
||||
selection: DiscordThreadPickerInbound,
|
||||
picker: WorkspaceGoalPicker,
|
||||
): Promise<void> {
|
||||
if (selection.author.id !== picker.authorId) {
|
||||
await selection.reply?.("Only the user who ran `/goals` can use this picker.");
|
||||
return;
|
||||
}
|
||||
const index = Number.parseInt(selection.optionId, 10);
|
||||
const entry = Number.isInteger(index) ? picker.entries[index] : undefined;
|
||||
if (!entry) {
|
||||
await selection.update?.("That goal choice is no longer available.");
|
||||
return;
|
||||
}
|
||||
this.#goalPickersById.delete(selection.pickerId);
|
||||
await this.#showGoalActionPicker(selection, picker, entry);
|
||||
}
|
||||
|
||||
async #handleGoalActionSelection(
|
||||
selection: DiscordThreadPickerInbound,
|
||||
picker: WorkspaceGoalActionPicker,
|
||||
): Promise<void> {
|
||||
if (selection.author.id !== picker.authorId) {
|
||||
await selection.reply?.("Only the user who ran `/goals` can use this picker.");
|
||||
return;
|
||||
}
|
||||
const action = selection.optionId;
|
||||
this.#goalActionPickersById.delete(selection.pickerId);
|
||||
if (action === "open") {
|
||||
try {
|
||||
const session = await this.#materializeWorkspaceThread(picker.entry.id, {
|
||||
author: selection.author,
|
||||
});
|
||||
const updatedEntry = {
|
||||
...picker.entry,
|
||||
discordThreadId: session.discordThreadId,
|
||||
};
|
||||
await this.#showGoalActionPicker(selection, picker, updatedEntry, {
|
||||
prefix: `Opened ${session.title}: <#${session.discordThreadId}>`,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateOrReply(
|
||||
selection,
|
||||
`Failed to open ${picker.entry.title}: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "clear") {
|
||||
try {
|
||||
await this.client.clearThreadGoal({ threadId: picker.entry.id });
|
||||
await updateOrReply(
|
||||
selection,
|
||||
`Cleared goal for ${picker.entry.title}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
await updateOrReply(
|
||||
selection,
|
||||
`Failed to clear goal for ${picker.entry.title}: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const status = action.startsWith("status:")
|
||||
? action.slice("status:".length)
|
||||
: "";
|
||||
if (
|
||||
status === "active" ||
|
||||
status === "paused" ||
|
||||
status === "budgetLimited" ||
|
||||
status === "complete"
|
||||
) {
|
||||
try {
|
||||
const response = await this.client.setThreadGoal({
|
||||
threadId: picker.entry.id,
|
||||
status,
|
||||
});
|
||||
await this.#showGoalActionPicker(
|
||||
selection,
|
||||
picker,
|
||||
{ ...picker.entry, goal: response.goal },
|
||||
{ prefix: `Set goal status to ${status}.` },
|
||||
);
|
||||
} catch (error) {
|
||||
await updateOrReply(
|
||||
selection,
|
||||
`Failed to update goal for ${picker.entry.title}: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await selection.update?.("That goal action is no longer available.");
|
||||
}
|
||||
|
||||
async #showGoalActionPicker(
|
||||
selection: Pick<
|
||||
DiscordThreadPickerInbound,
|
||||
"update" | "updatePicker" | "reply"
|
||||
> & Pick<DiscordGoalsInbound, "replyPicker">,
|
||||
picker: Pick<WorkspaceGoalPicker, "channelId" | "authorId" | "workspace">,
|
||||
entry: WorkspaceGoalSummary,
|
||||
options: { prefix?: string } = {},
|
||||
): Promise<void> {
|
||||
const actions = goalActionOptions(entry);
|
||||
const text = goalActionText(picker.workspace, entry, options);
|
||||
const sendPicker = selection.updatePicker ?? selection.replyPicker;
|
||||
if (actions.length === 0 || !sendPicker) {
|
||||
await updateOrReply(selection, text);
|
||||
return;
|
||||
}
|
||||
const pickerId = `goal-actions-${randomUUID()}`;
|
||||
this.#goalActionPickersById.set(pickerId, {
|
||||
channelId: picker.channelId,
|
||||
authorId: picker.authorId,
|
||||
workspace: picker.workspace,
|
||||
entry,
|
||||
});
|
||||
await sendPicker({
|
||||
pickerId,
|
||||
text,
|
||||
options: actions,
|
||||
});
|
||||
}
|
||||
|
||||
async #handleThreadPickerReaction(reaction: DiscordReactionInbound): Promise<void> {
|
||||
if (!this.config.allowedUserIds.has(reaction.author.id)) {
|
||||
return;
|
||||
|
|
@ -1861,6 +2141,59 @@ export class DiscordCodexBridge {
|
|||
return [...byId.values()].sort((left, right) => right.updatedAt - left.updatedAt);
|
||||
}
|
||||
|
||||
async #listWorkspaceGoalSummaries(
|
||||
workspace: DiscordGatewayWorkspaceSurface,
|
||||
): Promise<WorkspaceGoalSummary[]> {
|
||||
const threads = (await this.#listWorkspaceThreads(workspace)).slice(
|
||||
0,
|
||||
threadPickerReactions.length,
|
||||
);
|
||||
return await Promise.all(
|
||||
threads.map(async (thread) => {
|
||||
try {
|
||||
const response = await this.client.getThreadGoal({
|
||||
threadId: thread.id,
|
||||
});
|
||||
return { ...thread, goal: response.goal };
|
||||
} catch (error) {
|
||||
return { ...thread, goalError: errorMessage(error) };
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async #goalSummaryForSession(
|
||||
session: DiscordBridgeSession,
|
||||
): Promise<WorkspaceGoalSummary> {
|
||||
try {
|
||||
const response = await this.client.getThreadGoal({
|
||||
threadId: session.codexThreadId,
|
||||
});
|
||||
return this.#goalSummaryFromSession(session, { goal: response.goal });
|
||||
} catch (error) {
|
||||
return this.#goalSummaryFromSession(session, {
|
||||
goalError: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#goalSummaryFromSession(
|
||||
session: DiscordBridgeSession,
|
||||
options: Pick<WorkspaceGoalSummary, "goal" | "goalError"> = {},
|
||||
): WorkspaceGoalSummary {
|
||||
return {
|
||||
id: session.codexThreadId,
|
||||
title: session.title,
|
||||
cwd: session.cwd ?? this.config.cwd ?? process.cwd(),
|
||||
status: this.#isSessionRunning(session, this.#requireState())
|
||||
? "active"
|
||||
: "open",
|
||||
updatedAt: Date.parse(session.createdAt) / 1000,
|
||||
discordThreadId: session.discordThreadId,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
async #listActiveCodexThreadSummaries(): Promise<WorkspaceThreadSummary[]> {
|
||||
const byId = new Map<string, WorkspaceThreadSummary>();
|
||||
const put = (summary: WorkspaceThreadSummary) => {
|
||||
|
|
@ -2044,6 +2377,47 @@ export class DiscordCodexBridge {
|
|||
return workspaces.find((workspace) => workspace.key === key);
|
||||
}
|
||||
|
||||
#sessionForDiscordThread(channelId: string): DiscordBridgeSession | undefined {
|
||||
const session = this.#requireState().sessions.find((candidate) =>
|
||||
candidate.discordThreadId === channelId
|
||||
);
|
||||
if (
|
||||
!session ||
|
||||
session.mode === "gateway" ||
|
||||
session.discordThreadId === session.parentChannelId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
#workspaceForGoalSession(
|
||||
session: DiscordBridgeSession,
|
||||
): DiscordGatewayWorkspaceSurface {
|
||||
const existing = this.#workspaceForChannel(session.discordThreadId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const cwd = workspaceCwdForPath(session.cwd, this.config.cwd);
|
||||
return {
|
||||
key: workspaceKey(cwd),
|
||||
cwd,
|
||||
title: workspaceTitle(cwd),
|
||||
discordThreadId: session.parentChannelId,
|
||||
delegationIds: [],
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
#workspaceForumForChannel(
|
||||
channelId: string,
|
||||
): DiscordGatewayWorkspaceSurface | undefined {
|
||||
return this.#requireState().gateway?.workspaces?.find((workspace) =>
|
||||
workspace.discordThreadId === channelId
|
||||
);
|
||||
}
|
||||
|
||||
async #mirrorDelegationResultToTaskThread(
|
||||
delegation: DiscordGatewayDelegation,
|
||||
): Promise<void> {
|
||||
|
|
@ -3241,6 +3615,100 @@ function activeThreadStatusLines(
|
|||
});
|
||||
}
|
||||
|
||||
function goalPickerText(
|
||||
workspace: DiscordGatewayWorkspaceSurface,
|
||||
entries: WorkspaceGoalSummary[],
|
||||
total: number,
|
||||
): string {
|
||||
return [
|
||||
`**Goals: ${workspace.title}**`,
|
||||
`Dir: \`${workspace.cwd}\``,
|
||||
"",
|
||||
...entries.map((entry, index) => {
|
||||
const link = entry.discordThreadId ? `<#${entry.discordThreadId}>` : "`not opened`";
|
||||
const title = truncateDiscordThreadName(entry.title);
|
||||
return `${threadPickerReactions[index]} ${link} ${title} - ${goalSummaryText(entry)}`;
|
||||
}),
|
||||
total > entries.length ? `Showing newest ${entries.length} of ${total}.` : undefined,
|
||||
"",
|
||||
"Choose a number to manage that thread's goal.",
|
||||
].filter((line): line is string => line !== undefined).join("\n");
|
||||
}
|
||||
|
||||
function goalActionText(
|
||||
workspace: DiscordGatewayWorkspaceSurface,
|
||||
entry: WorkspaceGoalSummary,
|
||||
options: { prefix?: string } = {},
|
||||
): string {
|
||||
const link = entry.discordThreadId ? `<#${entry.discordThreadId}>` : "`not opened`";
|
||||
const goal = entry.goal;
|
||||
return [
|
||||
options.prefix,
|
||||
`**Goal: ${truncateDiscordThreadName(entry.title)}**`,
|
||||
`Workspace: ${workspace.title}`,
|
||||
`Thread: ${link} \`${entry.id}\``,
|
||||
`Dir: \`${entry.cwd}\``,
|
||||
"",
|
||||
entry.goalError
|
||||
? `Goal: unavailable (${entry.goalError})`
|
||||
: goal
|
||||
? [
|
||||
`Goal: \`${goal.status}\` ${previewText(firstLine(goal.objective) ?? goal.objective, 180)}`,
|
||||
`Usage: ${goal.tokensUsed} tokens, ${Math.round(goal.timeUsedSeconds)}s${
|
||||
goal.tokenBudget ? ` of ${goal.tokenBudget} tokens` : ""
|
||||
}`,
|
||||
].join("\n")
|
||||
: "Goal: none",
|
||||
"",
|
||||
goalActionOptions(entry).length > 0
|
||||
? "Choose an action."
|
||||
: entry.goal
|
||||
? "No goal actions are available for this thread."
|
||||
: "Use `/goals objective:<objective>` in an opened Discord thread to create one.",
|
||||
].filter((line): line is string => line !== undefined).join("\n");
|
||||
}
|
||||
|
||||
function hasGoalMutation(command: DiscordGoalsInbound): boolean {
|
||||
return command.objective !== undefined ||
|
||||
command.goalStatus !== undefined ||
|
||||
command.tokenBudget !== undefined;
|
||||
}
|
||||
|
||||
function goalActionOptions(
|
||||
entry: WorkspaceGoalSummary,
|
||||
): Array<{ id: string; label: string }> {
|
||||
const options: Array<{ id: string; label: string }> = [];
|
||||
if (!entry.discordThreadId) {
|
||||
options.push({ id: "open", label: "Open" });
|
||||
}
|
||||
if (entry.goal && !entry.goalError) {
|
||||
if (entry.goal.status !== "active") {
|
||||
options.push({ id: "status:active", label: "Active" });
|
||||
}
|
||||
if (entry.goal.status !== "paused") {
|
||||
options.push({ id: "status:paused", label: "Pause" });
|
||||
}
|
||||
if (entry.goal.status !== "complete") {
|
||||
options.push({ id: "status:complete", label: "Complete" });
|
||||
}
|
||||
options.push({ id: "clear", label: "Clear" });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function goalSummaryText(entry: WorkspaceGoalSummary): string {
|
||||
if (entry.goalError) {
|
||||
return `goal unavailable (${entry.goalError})`;
|
||||
}
|
||||
if (!entry.goal) {
|
||||
return "no goal";
|
||||
}
|
||||
return `\`${entry.goal.status}\` ${previewText(
|
||||
firstLine(entry.goal.objective) ?? entry.goal.objective,
|
||||
120,
|
||||
)}`;
|
||||
}
|
||||
|
||||
function threadPickerText(
|
||||
workspace: DiscordGatewayWorkspaceSurface,
|
||||
threads: WorkspaceThreadSummary[],
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from "discord.js";
|
||||
|
||||
import { splitDiscordMessage } from "./bridge.ts";
|
||||
import type { v2 } from "@peezy.tech/codex-flows/generated";
|
||||
import {
|
||||
createDiscordBridgeLogger,
|
||||
type DiscordBridgeLogger,
|
||||
|
|
@ -459,6 +460,13 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
|||
allowedMentions: emptyAllowedMentions(),
|
||||
});
|
||||
};
|
||||
const updatePicker = async (picker: DiscordEphemeralPicker) => {
|
||||
await interaction.editReply({
|
||||
content: picker.text,
|
||||
components: threadPickerComponents(picker),
|
||||
allowedMentions: emptyAllowedMentions(),
|
||||
});
|
||||
};
|
||||
this.#handlers?.onInbound({
|
||||
kind: "threadPicker",
|
||||
channelId: interaction.channelId,
|
||||
|
|
@ -475,6 +483,7 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
|||
createdAt: new Date().toISOString(),
|
||||
reply,
|
||||
update,
|
||||
updatePicker,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -485,12 +494,17 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
|||
interaction.commandName !== "clear" &&
|
||||
interaction.commandName !== "clear-webhooks" &&
|
||||
interaction.commandName !== "status" &&
|
||||
interaction.commandName !== "threads"
|
||||
interaction.commandName !== "threads" &&
|
||||
interaction.commandName !== "goals"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const channelId = interaction.channelId;
|
||||
if (interaction.commandName === "status" || interaction.commandName === "threads") {
|
||||
if (
|
||||
interaction.commandName === "status" ||
|
||||
interaction.commandName === "threads" ||
|
||||
interaction.commandName === "goals"
|
||||
) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
}
|
||||
const reply = async (text: string) => {
|
||||
|
|
@ -588,6 +602,48 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (interaction.commandName === "goals") {
|
||||
const goalStatus = goalStatusFromString(
|
||||
interaction.options.getString("status"),
|
||||
);
|
||||
const objective = interaction.options.getString("objective")?.trim() ||
|
||||
undefined;
|
||||
const tokenBudget = interaction.options.getInteger("token_budget") ??
|
||||
undefined;
|
||||
const clear = interaction.options.getBoolean("clear") ?? undefined;
|
||||
const replyPicker = async (picker: DiscordEphemeralPicker) => {
|
||||
const payload = {
|
||||
content: picker.text,
|
||||
components: threadPickerComponents(picker),
|
||||
allowedMentions: emptyAllowedMentions(),
|
||||
};
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply(payload);
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ ...payload, ephemeral: true });
|
||||
};
|
||||
this.#handlers?.onInbound({
|
||||
kind: "goals",
|
||||
channelId,
|
||||
guildId: interaction.guildId ?? undefined,
|
||||
author: {
|
||||
id: interaction.user.id,
|
||||
name: interaction.member && "displayName" in interaction.member
|
||||
? String(interaction.member.displayName)
|
||||
: interaction.user.globalName || interaction.user.username,
|
||||
isBot: interaction.user.bot,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
objective,
|
||||
goalStatus,
|
||||
tokenBudget,
|
||||
clear,
|
||||
reply,
|
||||
replyPicker,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.#handlers?.onInbound({
|
||||
kind: "clear",
|
||||
channelId,
|
||||
|
|
@ -682,6 +738,42 @@ function discordBridgeCommands(): ApplicationCommandDataResolvable[] {
|
|||
name: "threads",
|
||||
description: "List Codex threads for this workspace",
|
||||
},
|
||||
{
|
||||
name: "goals",
|
||||
description: "Manage Codex goals for this workspace or thread",
|
||||
options: [
|
||||
{
|
||||
name: "objective",
|
||||
description: "Create or update this thread's goal objective",
|
||||
type: 3,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
description: "Set this thread's goal status",
|
||||
type: 3,
|
||||
required: false,
|
||||
choices: [
|
||||
{ name: "active", value: "active" },
|
||||
{ name: "paused", value: "paused" },
|
||||
{ name: "budget limited", value: "budgetLimited" },
|
||||
{ name: "complete", value: "complete" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "token_budget",
|
||||
description: "Optional token budget for this thread's goal",
|
||||
type: 4,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: "Clear this thread's goal",
|
||||
type: 5,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -691,6 +783,18 @@ function commandName(command: ApplicationCommandDataResolvable): string {
|
|||
: "unknown";
|
||||
}
|
||||
|
||||
function goalStatusFromString(value: string | null): v2.ThreadGoalStatus | undefined {
|
||||
if (
|
||||
value === "active" ||
|
||||
value === "paused" ||
|
||||
value === "budgetLimited" ||
|
||||
value === "complete"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function threadPickerComponents(
|
||||
picker: DiscordEphemeralPicker,
|
||||
): ActionRowBuilder<ButtonBuilder>[] {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,20 @@ export type DiscordThreadsInbound = {
|
|||
replyPicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordGoalsInbound = {
|
||||
kind: "goals";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
author: DiscordAuthor;
|
||||
createdAt: string;
|
||||
objective?: string;
|
||||
goalStatus?: v2.ThreadGoalStatus;
|
||||
tokenBudget?: number;
|
||||
clear?: boolean;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
replyPicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordThreadPickerInbound = {
|
||||
kind: "threadPicker";
|
||||
channelId: string;
|
||||
|
|
@ -118,6 +132,7 @@ export type DiscordThreadPickerInbound = {
|
|||
createdAt: string;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
update?: (text: string) => Promise<void>;
|
||||
updatePicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordReactionInbound = {
|
||||
|
|
@ -137,6 +152,7 @@ export type DiscordInbound =
|
|||
| DiscordClearWebhooksInbound
|
||||
| DiscordStatusInbound
|
||||
| DiscordThreadsInbound
|
||||
| DiscordGoalsInbound
|
||||
| DiscordThreadPickerInbound
|
||||
| DiscordReactionInbound;
|
||||
|
||||
|
|
@ -204,7 +220,9 @@ export type CodexBridgeClient = {
|
|||
readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse>;
|
||||
injectThreadItems(params: v2.ThreadInjectItemsParams): Promise<v2.ThreadInjectItemsResponse>;
|
||||
listThreads(params: v2.ThreadListParams): Promise<v2.ThreadListResponse>;
|
||||
setThreadGoal(params: v2.ThreadGoalSetParams): Promise<v2.ThreadGoalSetResponse>;
|
||||
getThreadGoal(params: v2.ThreadGoalGetParams): Promise<v2.ThreadGoalGetResponse>;
|
||||
clearThreadGoal(params: v2.ThreadGoalClearParams): Promise<v2.ThreadGoalClearResponse>;
|
||||
respond(id: string | number, result: unknown): void;
|
||||
respondError(id: string | number, code: number, message: string, data?: unknown): void;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1387,6 +1387,237 @@ describe("DiscordCodexBridge", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("goals command manages thread goals from workspace forum posts", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "discord-goals-"));
|
||||
await mkdir(path.join(root, "alpha", "project"), { recursive: true });
|
||||
const client = new FakeCodexClient();
|
||||
client.threads = [
|
||||
testThread({
|
||||
id: "codex-goal",
|
||||
cwd: path.join(root, "alpha", "project"),
|
||||
name: "Goal thread",
|
||||
updatedAt: 30,
|
||||
}),
|
||||
];
|
||||
client.threadGoals.set("codex-goal", {
|
||||
threadId: "codex-goal",
|
||||
objective: "Ship goal management",
|
||||
status: "active",
|
||||
tokenBudget: null,
|
||||
tokensUsed: 42,
|
||||
timeUsedSeconds: 9,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
});
|
||||
const transport = new FakeDiscordTransport();
|
||||
const bridge = new DiscordCodexBridge({
|
||||
client,
|
||||
transport,
|
||||
store: new MemoryStateStore(),
|
||||
config: testConfig({
|
||||
cwd: root,
|
||||
gateway: {
|
||||
homeChannelId: "home-channel",
|
||||
workspaceForumChannelId: "workspace-forum",
|
||||
taskThreadsChannelId: "task-channel",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await bridge.start();
|
||||
expect(transport.createdForumPosts.map((post) => post.name)).toEqual([
|
||||
"alpha",
|
||||
]);
|
||||
|
||||
const replies: string[] = [];
|
||||
transport.emit({
|
||||
kind: "goals",
|
||||
channelId: "task-channel",
|
||||
author: { id: "user-1", name: "Peezy", isBot: false },
|
||||
createdAt: "2026-05-15T00:00:00.000Z",
|
||||
reply: async (text) => {
|
||||
replies.push(text);
|
||||
},
|
||||
replyPicker: transport.threadsReplyPicker(),
|
||||
});
|
||||
await waitFor(() => replies.length === 1);
|
||||
expect(replies[0]).toBe(
|
||||
"Run `/goals` in a workspace forum post or opened Codex thread.",
|
||||
);
|
||||
|
||||
transport.emit({
|
||||
kind: "goals",
|
||||
channelId: "forum-post-1",
|
||||
author: { id: "user-1", name: "Peezy", isBot: false },
|
||||
createdAt: "2026-05-15T00:00:01.000Z",
|
||||
reply: async (text) => {
|
||||
replies.push(text);
|
||||
},
|
||||
replyPicker: transport.threadsReplyPicker(),
|
||||
});
|
||||
await waitFor(() => transport.ephemeralPickers.length === 1);
|
||||
const picker = transport.ephemeralPickers[0];
|
||||
expect(picker?.text).toContain("**Goals: alpha**");
|
||||
expect(picker?.text).toContain("1️⃣ `not opened` Goal thread - `active` Ship goal management");
|
||||
expect(picker?.options).toEqual([{ id: "0", label: "1" }]);
|
||||
|
||||
transport.emitThreadPicker({
|
||||
pickerId: picker?.pickerId ?? "",
|
||||
optionId: "0",
|
||||
});
|
||||
await waitFor(() => transport.ephemeralPickers.length === 2);
|
||||
const actionPicker = transport.ephemeralPickers[1];
|
||||
expect(actionPicker?.text).toContain("**Goal: Goal thread**");
|
||||
expect(actionPicker?.text).toContain("Goal: `active` Ship goal management");
|
||||
expect(actionPicker?.options).toEqual([
|
||||
{ id: "open", label: "Open" },
|
||||
{ id: "status:paused", label: "Pause" },
|
||||
{ id: "status:complete", label: "Complete" },
|
||||
{ id: "clear", label: "Clear" },
|
||||
]);
|
||||
|
||||
transport.emitThreadPicker({
|
||||
pickerId: actionPicker?.pickerId ?? "",
|
||||
optionId: "status:complete",
|
||||
});
|
||||
await waitFor(() => client.setThreadGoalCalls.length === 1);
|
||||
expect(client.setThreadGoalCalls[0]).toEqual({
|
||||
threadId: "codex-goal",
|
||||
status: "complete",
|
||||
});
|
||||
await waitFor(() => transport.ephemeralPickers.length === 3);
|
||||
expect(transport.ephemeralPickers[2]?.text).toContain(
|
||||
"Set goal status to complete.",
|
||||
);
|
||||
expect(transport.ephemeralPickers[2]?.text).toContain(
|
||||
"Goal: `complete` Ship goal management",
|
||||
);
|
||||
} finally {
|
||||
await bridge.stop();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("goals command manages the current Discord thread goal", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "discord-thread-goals-"));
|
||||
const cwd = path.join(root, "alpha", "project");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
const client = new FakeCodexClient();
|
||||
const transport = new FakeDiscordTransport();
|
||||
const bridge = new DiscordCodexBridge({
|
||||
client,
|
||||
transport,
|
||||
store: new MemoryStateStore({
|
||||
...emptyState(),
|
||||
sessions: [
|
||||
{
|
||||
discordThreadId: "task-goal",
|
||||
parentChannelId: "task-channel",
|
||||
codexThreadId: "codex-thread-goal",
|
||||
title: "Goal task",
|
||||
createdAt: "2026-05-14T11:00:00.000Z",
|
||||
cwd,
|
||||
mode: "workspace",
|
||||
},
|
||||
],
|
||||
}),
|
||||
config: testConfig({
|
||||
cwd: root,
|
||||
gateway: {
|
||||
homeChannelId: "home-channel",
|
||||
workspaceForumChannelId: "workspace-forum",
|
||||
taskThreadsChannelId: "task-channel",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await bridge.start();
|
||||
|
||||
const replies: string[] = [];
|
||||
transport.emit({
|
||||
kind: "goals",
|
||||
channelId: "task-goal",
|
||||
author: { id: "user-1", name: "Peezy", isBot: false },
|
||||
createdAt: "2026-05-15T00:00:00.000Z",
|
||||
objective: "Improve delegation CRUD",
|
||||
goalStatus: "active",
|
||||
tokenBudget: 1234,
|
||||
reply: async (text) => {
|
||||
replies.push(text);
|
||||
},
|
||||
replyPicker: transport.threadsReplyPicker(),
|
||||
});
|
||||
await waitFor(() => client.setThreadGoalCalls.length === 1);
|
||||
expect(client.setThreadGoalCalls[0]).toEqual({
|
||||
threadId: "codex-thread-goal",
|
||||
objective: "Improve delegation CRUD",
|
||||
status: "active",
|
||||
tokenBudget: 1234,
|
||||
});
|
||||
await waitFor(() => transport.ephemeralPickers.length === 1);
|
||||
expect(transport.ephemeralPickers[0]?.text).toContain("Saved goal.");
|
||||
expect(transport.ephemeralPickers[0]?.text).toContain(
|
||||
"Goal: `active` Improve delegation CRUD",
|
||||
);
|
||||
|
||||
transport.emit({
|
||||
kind: "goals",
|
||||
channelId: "task-goal",
|
||||
author: { id: "user-1", name: "Peezy", isBot: false },
|
||||
createdAt: "2026-05-15T00:00:01.000Z",
|
||||
reply: async (text) => {
|
||||
replies.push(text);
|
||||
},
|
||||
replyPicker: transport.threadsReplyPicker(),
|
||||
});
|
||||
await waitFor(() => transport.ephemeralPickers.length === 2);
|
||||
const picker = transport.ephemeralPickers[1];
|
||||
expect(picker?.text).toContain("**Goal: Goal task**");
|
||||
expect(picker?.text).toContain("Thread: <#task-goal> `codex-thread-goal`");
|
||||
expect(picker?.options).toEqual([
|
||||
{ id: "status:paused", label: "Pause" },
|
||||
{ id: "status:complete", label: "Complete" },
|
||||
{ id: "clear", label: "Clear" },
|
||||
]);
|
||||
|
||||
transport.emitThreadPicker({
|
||||
pickerId: picker?.pickerId ?? "",
|
||||
optionId: "status:complete",
|
||||
});
|
||||
await waitFor(() => client.setThreadGoalCalls.length === 2);
|
||||
expect(client.setThreadGoalCalls[1]).toEqual({
|
||||
threadId: "codex-thread-goal",
|
||||
status: "complete",
|
||||
});
|
||||
await waitFor(() => transport.ephemeralPickers.length === 3);
|
||||
expect(transport.ephemeralPickers[2]?.text).toContain(
|
||||
"Set goal status to complete.",
|
||||
);
|
||||
|
||||
transport.emit({
|
||||
kind: "goals",
|
||||
channelId: "task-goal",
|
||||
author: { id: "user-1", name: "Peezy", isBot: false },
|
||||
createdAt: "2026-05-15T00:00:02.000Z",
|
||||
clear: true,
|
||||
reply: async (text) => {
|
||||
replies.push(text);
|
||||
},
|
||||
replyPicker: transport.threadsReplyPicker(),
|
||||
});
|
||||
await waitFor(() => client.clearThreadGoalCalls.length === 1);
|
||||
expect(client.clearThreadGoalCalls[0]).toEqual({
|
||||
threadId: "codex-thread-goal",
|
||||
});
|
||||
await waitFor(() => replies.includes("Cleared goal for Goal task."));
|
||||
} finally {
|
||||
await bridge.stop();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resumes a configured gateway main thread without creating Discord threads", async () => {
|
||||
const client = new FakeCodexClient();
|
||||
const transport = new FakeDiscordTransport();
|
||||
|
|
@ -3876,6 +4107,8 @@ class FakeCodexClient implements CodexBridgeClient {
|
|||
injectThreadItemsCalls: v2.ThreadInjectItemsParams[] = [];
|
||||
listThreadsCalls: v2.ThreadListParams[] = [];
|
||||
getThreadGoalCalls: v2.ThreadGoalGetParams[] = [];
|
||||
setThreadGoalCalls: v2.ThreadGoalSetParams[] = [];
|
||||
clearThreadGoalCalls: v2.ThreadGoalClearParams[] = [];
|
||||
responses: Array<{ id: string | number; result: unknown }> = [];
|
||||
responseErrors: Array<{
|
||||
id: string | number;
|
||||
|
|
@ -4010,6 +4243,33 @@ class FakeCodexClient implements CodexBridgeClient {
|
|||
};
|
||||
}
|
||||
|
||||
async setThreadGoal(
|
||||
params: v2.ThreadGoalSetParams,
|
||||
): Promise<v2.ThreadGoalSetResponse> {
|
||||
this.setThreadGoalCalls.push(params);
|
||||
const existing = this.threadGoals.get(params.threadId);
|
||||
const goal: v2.ThreadGoal = {
|
||||
threadId: params.threadId,
|
||||
objective: params.objective ?? existing?.objective ?? "Goal",
|
||||
status: params.status ?? existing?.status ?? "active",
|
||||
tokenBudget: params.tokenBudget ?? existing?.tokenBudget ?? null,
|
||||
tokensUsed: existing?.tokensUsed ?? 0,
|
||||
timeUsedSeconds: existing?.timeUsedSeconds ?? 0,
|
||||
createdAt: existing?.createdAt ?? 1,
|
||||
updatedAt: (existing?.updatedAt ?? 1) + 1,
|
||||
};
|
||||
this.threadGoals.set(params.threadId, goal);
|
||||
return { goal };
|
||||
}
|
||||
|
||||
async clearThreadGoal(
|
||||
params: v2.ThreadGoalClearParams,
|
||||
): Promise<v2.ThreadGoalClearResponse> {
|
||||
this.clearThreadGoalCalls.push(params);
|
||||
const cleared = this.threadGoals.delete(params.threadId);
|
||||
return { cleared };
|
||||
}
|
||||
|
||||
respond(id: string | number, result: unknown): void {
|
||||
this.responses.push({ id, result });
|
||||
}
|
||||
|
|
@ -4232,6 +4492,13 @@ class FakeDiscordTransport implements DiscordBridgeTransport {
|
|||
reply: async (text) => {
|
||||
this.ephemeralUpdates.push({ pickerId: input.pickerId, text });
|
||||
},
|
||||
updatePicker: async (picker) => {
|
||||
this.ephemeralUpdates.push({
|
||||
pickerId: input.pickerId,
|
||||
text: picker.text,
|
||||
});
|
||||
this.ephemeralPickers.push(picker);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue