forum mode
This commit is contained in:
parent
84400f8689
commit
08263e60ec
13 changed files with 3394 additions and 178 deletions
|
|
@ -13,8 +13,10 @@ Set these environment values before starting the bridge:
|
|||
```bash
|
||||
CODEX_DISCORD_HOME_CHANNEL_ID=1502107617512919220
|
||||
CODEX_DISCORD_MAIN_THREAD_ID=019e2509-ddbb-7380-b97b-41575092d86b
|
||||
CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID=1502107617512919221
|
||||
CODEX_DISCORD_TASK_THREADS_CHANNEL_ID=1502107617512919222
|
||||
CODEX_DISCORD_ALLOWED_CHANNEL_IDS=1502107617512919220
|
||||
CODEX_DISCORD_DIR=/home/peezy/codex-fork-workspace/codex-flows
|
||||
CODEX_DISCORD_DIR=/home/peezy
|
||||
CODEX_FLOW_BACKEND_URL=http://127.0.0.1:8090
|
||||
CODEX_DISCORD_HOOK_SPOOL_DIR=/home/peezy/.codex/discord-bridge/stop-hooks
|
||||
```
|
||||
|
|
@ -30,7 +32,9 @@ In the 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` 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
|
||||
|
||||
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
|
||||
|
|
@ -68,6 +72,55 @@ Gateway state stores delegation records, including optional Discord detail
|
|||
thread ids for noisy work. Delegated Codex sessions do not receive the privileged
|
||||
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:
|
||||
|
||||
```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
|
||||
`CODEX_DISCORD_DIR=/home/peezy`; a delegated cwd such as
|
||||
`/home/peezy/codex-fork-workspace/codex-flows` maps to the
|
||||
`/home/peezy/codex-fork-workspace` workspace post. Hidden folders and
|
||||
`node_modules` are skipped. Workspace posts are compact dashboards that only
|
||||
show Codex threads already opened into Discord. Run `/threads` in a workspace
|
||||
post to list all Codex threads for that workspace; the bridge replies with an
|
||||
ephemeral numbered button picker visible only to the command sender. Choosing a
|
||||
number opens or reuses one Discord task thread in the task thread channel, and
|
||||
messages in that Discord thread are routed directly to the opened Codex thread.
|
||||
|
||||
When the workbench is enabled:
|
||||
|
||||
- `start_delegation` and `resume_delegation` create or reuse the workspace forum
|
||||
post for the top-level workspace containing the delegation cwd
|
||||
- bridge startup creates missing workspace forum posts for discoverable folders
|
||||
under the main workspace root
|
||||
- workspace dashboards list only open Discord task threads for that workspace
|
||||
- `/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
|
||||
- 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
|
||||
- main-thread injection and wake behavior still follow the delegation return
|
||||
mode
|
||||
|
||||
If both workbench channels are omitted, the workbench is disabled and the bridge
|
||||
keeps the legacy home-channel result mirroring behavior. Setting only one
|
||||
workbench channel is rejected as an invalid partial configuration.
|
||||
|
||||
Delegations support return modes:
|
||||
|
||||
- `wake_on_done`: inject and mirror the result, then wake the main operator when idle
|
||||
|
|
@ -78,15 +131,17 @@ Delegations support return modes:
|
|||
|
||||
Automatic result return uses `thread/inject_items` to append structured
|
||||
delegation results to the main operator thread's model-visible history. Codex
|
||||
`Stop` hooks, not background thread polling, drive automatic result return:
|
||||
the global hook writes durable Stop events into the spool directory, and the
|
||||
gateway drains that spool on startup and while running. Starting a main-thread
|
||||
turn is a separate wake step, so long-running main goals are not interrupted;
|
||||
wakes are queued until the main operator thread is idle.
|
||||
hooks, not background thread polling, drive automatic result return and passive
|
||||
observability. The global hook writes durable lifecycle events into the spool
|
||||
directory, and the gateway drains that spool on startup and while running.
|
||||
Starting a main-thread turn is a separate wake step, so long-running main goals
|
||||
are not interrupted; wakes are queued until the main operator thread is idle.
|
||||
For sessions that were not created through the gateway, the same hook stream
|
||||
updates an observed-thread index used by `/threads`.
|
||||
|
||||
## Codex Stop Hook
|
||||
## Codex Hooks
|
||||
|
||||
Install the global hook once for the Codex runtime that backs the gateway:
|
||||
Install the global hooks once for the Codex runtime that backs the gateway:
|
||||
|
||||
```bash
|
||||
codex-discord-bridge hook install
|
||||
|
|
@ -102,19 +157,40 @@ The installer enables the current hooks feature in `~/.codex/config.toml`:
|
|||
hooks = true
|
||||
```
|
||||
|
||||
It also registers the Stop hook in `~/.codex/hooks.json`:
|
||||
It also registers passive observability hooks in `~/.codex/hooks.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "codex-discord-bridge hook event",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "codex-discord-bridge hook event",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "codex-discord-bridge hook stop",
|
||||
"timeout": 10,
|
||||
"statusMessage": "Recording Discord gateway Stop event"
|
||||
"command": "codex-discord-bridge hook event",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -123,6 +199,11 @@ It also registers the Stop hook in `~/.codex/hooks.json`:
|
|||
}
|
||||
```
|
||||
|
||||
The installer also registers `PreToolUse`, `PermissionRequest`, and
|
||||
`PostToolUse` with the same command. Those higher-volume events update local
|
||||
observed-thread metadata such as status, current tool, or waiting reason; they
|
||||
do not create Discord messages.
|
||||
|
||||
For package-on-demand installs, write a `bunx` command instead:
|
||||
|
||||
```bash
|
||||
|
|
@ -131,9 +212,12 @@ codex-discord-bridge hook install --bunx-package @peezy.tech/codex-flows
|
|||
```
|
||||
|
||||
The hook is intentionally dumb: it does not read gateway state or call the
|
||||
backend. It only writes idempotent Stop-event files. The gateway ignores unknown
|
||||
sessions, treats known delegated sessions according to their return mode, and
|
||||
uses main-operator Stop events to drain queued wakes.
|
||||
backend. It only writes idempotent lifecycle-event files and lets Codex
|
||||
continue. The gateway treats known delegated `Stop` events according to their
|
||||
return mode, uses main-operator `Stop` events to drain queued wakes, and records
|
||||
unknown non-main sessions as observed threads. Observed threads are visible from
|
||||
`/threads` for their workspace and can be opened into the task thread channel
|
||||
on demand.
|
||||
|
||||
After changing hook configuration, restart the Codex runtime that backs the
|
||||
gateway and trust the hook when Codex asks for review. `hooks/list` should show
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -290,15 +290,46 @@ function gatewayConfig(
|
|||
stringFlag(flags, "gateway-main-thread-id") ??
|
||||
env.CODEX_DISCORD_MAIN_THREAD_ID ??
|
||||
env.CODEX_DISCORD_GATEWAY_MAIN_THREAD_ID;
|
||||
const workspaceForumChannelId =
|
||||
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 =
|
||||
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;
|
||||
if (!homeChannelId) {
|
||||
if (mainThreadId) {
|
||||
throw new Error("Cannot set a gateway main thread without a gateway home channel.");
|
||||
}
|
||||
if (workspaceForumChannelId || taskThreadsChannelId) {
|
||||
throw new Error("Cannot set Discord workbench channels without a gateway home channel.");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (Boolean(workspaceForumChannelId) !== Boolean(taskThreadsChannelId)) {
|
||||
throw new Error(
|
||||
"Discord workbench requires both workspace forum and task threads channels.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
workspaceForumChannelId &&
|
||||
taskThreadsChannelId &&
|
||||
(workspaceForumChannelId === homeChannelId ||
|
||||
taskThreadsChannelId === homeChannelId ||
|
||||
workspaceForumChannelId === taskThreadsChannelId)
|
||||
) {
|
||||
throw new Error(
|
||||
"Discord workbench channels must be separate from the gateway home channel and each other.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
homeChannelId,
|
||||
mainThreadId,
|
||||
workspaceForumChannelId,
|
||||
taskThreadsChannelId,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -369,8 +400,11 @@ Options:
|
|||
--allowed-channel-ids <ids> Comma-separated parent channel ids
|
||||
--home-channel-id <id> Enable gateway mode for one Discord home channel
|
||||
--main-thread-id <id> Resume an existing Codex operator thread for gateway mode
|
||||
--workspace-forum-channel-id <id>
|
||||
Optional workbench forum channel for workspace posts
|
||||
--task-threads-channel-id <id> Optional workbench text channel for task threads
|
||||
--flow-backend-url <url> Optional codex-flow-systemd-local backend URL
|
||||
--hook-spool-dir <path> Directory drained for Codex Stop hook events
|
||||
--hook-spool-dir <path> Directory drained for Codex hook events
|
||||
[dir] Optional Codex thread directory, resolved from home
|
||||
--dir <path> Codex thread directory, resolved from home
|
||||
--cwd <path> Alias for --dir
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
Client,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
Partials,
|
||||
type Interaction,
|
||||
type ApplicationCommandDataResolvable,
|
||||
type Message,
|
||||
type MessageReaction,
|
||||
type PartialMessageReaction,
|
||||
type PartialUser,
|
||||
type User,
|
||||
} from "discord.js";
|
||||
|
||||
import { splitDiscordMessage } from "./bridge.ts";
|
||||
|
|
@ -12,6 +21,8 @@ import {
|
|||
type DiscordBridgeLogger,
|
||||
} from "./logger.ts";
|
||||
import type {
|
||||
DiscordBridgeCommandRegistration,
|
||||
DiscordEphemeralPicker,
|
||||
DiscordBridgeTransport,
|
||||
DiscordBridgeTransportHandlers,
|
||||
} from "./types.ts";
|
||||
|
|
@ -21,6 +32,8 @@ export type DiscordJsBridgeTransportOptions = {
|
|||
logger?: DiscordBridgeLogger;
|
||||
};
|
||||
|
||||
const threadPickerCustomIdPrefix = "codex_threads";
|
||||
|
||||
export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
||||
#token: string;
|
||||
#logger: DiscordBridgeLogger;
|
||||
|
|
@ -41,9 +54,11 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
|||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.GuildMessageReactions,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
|
||||
});
|
||||
this.#client = client;
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
|
|
@ -53,6 +68,13 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
|||
});
|
||||
});
|
||||
client.on(Events.MessageCreate, (message) => this.#handleMessage(message));
|
||||
client.on(Events.MessageReactionAdd, (reaction, user) =>
|
||||
void this.#handleReaction(reaction, user).catch((error) => {
|
||||
this.#logger.error("discord.reaction.failed", {
|
||||
error: errorMessage(error),
|
||||
});
|
||||
})
|
||||
);
|
||||
client.on(Events.InteractionCreate, (interaction) =>
|
||||
void this.#handleInteraction(interaction).catch((error) => {
|
||||
this.#logger.error("discord.interaction.failed", {
|
||||
|
|
@ -68,29 +90,35 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
|||
this.#client = undefined;
|
||||
}
|
||||
|
||||
async registerCommands(): Promise<void> {
|
||||
const application = this.#client?.application;
|
||||
if (!application) {
|
||||
async registerCommands(
|
||||
options: DiscordBridgeCommandRegistration = {},
|
||||
): Promise<void> {
|
||||
const client = await this.#readyClient();
|
||||
const commands = discordBridgeCommands();
|
||||
const commandNames = commands.map(commandName);
|
||||
const guildIds = await this.#guildIdsForCommandChannels(options.channelIds ?? []);
|
||||
if (guildIds.length === 0) {
|
||||
await client.application.commands.set(commands);
|
||||
this.#logger.info("discord.commands.registered", {
|
||||
scope: "global",
|
||||
commands: commandNames,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await application.commands.set([
|
||||
{
|
||||
name: "clear",
|
||||
description: "Delete inactive Codex bridge threads",
|
||||
},
|
||||
{
|
||||
name: "clear-webhooks",
|
||||
description: "Delete webhook-authored messages in this channel",
|
||||
options: [
|
||||
{
|
||||
name: "webhook_url",
|
||||
description: "Optional webhook URL to target a single webhook",
|
||||
type: 3,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
await client.application.commands.set([]);
|
||||
this.#logger.info("discord.commands.registered", {
|
||||
scope: "global-cleared",
|
||||
commands: [],
|
||||
});
|
||||
for (const guildId of guildIds) {
|
||||
const guild = await client.guilds.fetch(guildId);
|
||||
await guild.commands.set(commands);
|
||||
this.#logger.info("discord.commands.registered", {
|
||||
scope: "guild",
|
||||
guildId,
|
||||
commands: commandNames,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createThread(
|
||||
|
|
@ -130,10 +158,48 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
|||
return thread.id;
|
||||
}
|
||||
|
||||
async createForumPost(
|
||||
channelId: string,
|
||||
name: string,
|
||||
message: string,
|
||||
): Promise<{ threadId: string; messageId?: string }> {
|
||||
const client = this.#client;
|
||||
if (!client) {
|
||||
throw new Error("Discord bridge is not connected");
|
||||
}
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (!channel || typeof channel !== "object") {
|
||||
throw new Error(`Discord channel cannot create forum posts: ${channelId}`);
|
||||
}
|
||||
const threads = getThreadsManager(channel as ThreadCreatableChannel);
|
||||
if (!threads) {
|
||||
throw new Error(`Discord channel cannot create forum posts: ${channelId}`);
|
||||
}
|
||||
const thread = await threads.create({
|
||||
name,
|
||||
autoArchiveDuration: 10080,
|
||||
message: {
|
||||
content: splitDiscordMessage(message)[0] ?? "",
|
||||
allowedMentions: {
|
||||
parse: [],
|
||||
users: [],
|
||||
roles: [],
|
||||
repliedUser: false,
|
||||
},
|
||||
},
|
||||
reason: "Codex Discord bridge workspace post",
|
||||
});
|
||||
if (!thread.id) {
|
||||
throw new Error("Discord did not return a forum post thread id");
|
||||
}
|
||||
return { threadId: thread.id, messageId: thread.id };
|
||||
}
|
||||
|
||||
async sendMessage(channelId: string, text: string): Promise<string[]> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messageIds: string[] = [];
|
||||
for (const chunk of splitDiscordMessage(text)) {
|
||||
const chunks = splitDiscordMessage(text);
|
||||
for (const chunk of chunks) {
|
||||
const sent = await channel.send({
|
||||
content: chunk,
|
||||
allowedMentions: {
|
||||
|
|
@ -253,6 +319,25 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
|||
}
|
||||
}
|
||||
|
||||
async addReactions(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
reactions: string[],
|
||||
): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messages = getMessagesManager(channel);
|
||||
if (!messages) {
|
||||
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
|
||||
}
|
||||
const message = await messages.fetch(messageId);
|
||||
if (!message.react) {
|
||||
throw new Error(`Discord message cannot receive reactions: ${messageId}`);
|
||||
}
|
||||
for (const reaction of reactions) {
|
||||
await message.react(reaction);
|
||||
}
|
||||
}
|
||||
|
||||
async pinMessage(channelId: string, messageId: string): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messages = getMessagesManager(channel);
|
||||
|
|
@ -323,28 +408,106 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
|||
});
|
||||
}
|
||||
|
||||
async #handleReaction(
|
||||
reaction: MessageReaction | PartialMessageReaction,
|
||||
user: User | PartialUser,
|
||||
): Promise<void> {
|
||||
if (user.bot) {
|
||||
return;
|
||||
}
|
||||
const fullReaction = reaction.partial ? await reaction.fetch() : reaction;
|
||||
const message = fullReaction.message.partial
|
||||
? await fullReaction.message.fetch()
|
||||
: fullReaction.message;
|
||||
const emoji = fullReaction.emoji.name ?? fullReaction.emoji.id;
|
||||
if (!emoji) {
|
||||
return;
|
||||
}
|
||||
this.#handlers?.onInbound({
|
||||
kind: "reaction",
|
||||
channelId: message.channelId,
|
||||
guildId: message.guildId ?? undefined,
|
||||
messageId: message.id,
|
||||
emoji,
|
||||
author: {
|
||||
id: user.id,
|
||||
name: user.globalName ?? user.username ?? user.id,
|
||||
isBot: user.bot,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async #handleInteraction(interaction: Interaction): Promise<void> {
|
||||
if (interaction.isButton()) {
|
||||
const selection = threadPickerSelectionFromCustomId(interaction.customId);
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
await interaction.deferUpdate();
|
||||
const reply = async (text: string) => {
|
||||
await interaction.followUp({
|
||||
content: text,
|
||||
ephemeral: true,
|
||||
allowedMentions: emptyAllowedMentions(),
|
||||
});
|
||||
};
|
||||
const update = async (text: string) => {
|
||||
await interaction.editReply({
|
||||
content: text,
|
||||
components: [],
|
||||
allowedMentions: emptyAllowedMentions(),
|
||||
});
|
||||
};
|
||||
this.#handlers?.onInbound({
|
||||
kind: "threadPicker",
|
||||
channelId: interaction.channelId,
|
||||
guildId: interaction.guildId ?? undefined,
|
||||
pickerId: selection.pickerId,
|
||||
optionId: selection.optionId,
|
||||
author: {
|
||||
id: interaction.user.id,
|
||||
name: interaction.member && "displayName" in interaction.member
|
||||
? String(interaction.member.displayName)
|
||||
: interaction.user.globalName || interaction.user.username,
|
||||
isBot: interaction.user.bot,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
reply,
|
||||
update,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!interaction.isChatInputCommand()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
interaction.commandName !== "clear" &&
|
||||
interaction.commandName !== "clear-webhooks"
|
||||
interaction.commandName !== "clear-webhooks" &&
|
||||
interaction.commandName !== "status" &&
|
||||
interaction.commandName !== "threads"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const channelId = interaction.channelId;
|
||||
if (interaction.commandName === "status" || interaction.commandName === "threads") {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
}
|
||||
const reply = async (text: string) => {
|
||||
await interaction.reply({
|
||||
const payload = {
|
||||
content: text,
|
||||
ephemeral: true,
|
||||
allowedMentions: {
|
||||
parse: [],
|
||||
users: [],
|
||||
roles: [],
|
||||
repliedUser: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply(payload);
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ ...payload, ephemeral: true });
|
||||
};
|
||||
if (interaction.commandName === "clear-webhooks") {
|
||||
const webhookUrl = interaction.options.getString("webhook_url") ?? undefined;
|
||||
|
|
@ -365,6 +528,66 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (interaction.commandName === "status") {
|
||||
const replyPicker = async (picker: DiscordEphemeralPicker) => {
|
||||
const payload = {
|
||||
content: picker.text,
|
||||
components: threadPickerComponents(picker),
|
||||
allowedMentions: emptyAllowedMentions(),
|
||||
};
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply(payload);
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ ...payload, ephemeral: true });
|
||||
};
|
||||
this.#handlers?.onInbound({
|
||||
kind: "status",
|
||||
channelId,
|
||||
guildId: interaction.guildId ?? undefined,
|
||||
author: {
|
||||
id: interaction.user.id,
|
||||
name: interaction.member && "displayName" in interaction.member
|
||||
? String(interaction.member.displayName)
|
||||
: interaction.user.globalName || interaction.user.username,
|
||||
isBot: interaction.user.bot,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
reply,
|
||||
replyPicker,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (interaction.commandName === "threads") {
|
||||
const replyPicker = async (picker: DiscordEphemeralPicker) => {
|
||||
const payload = {
|
||||
content: picker.text,
|
||||
components: threadPickerComponents(picker),
|
||||
allowedMentions: emptyAllowedMentions(),
|
||||
};
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply(payload);
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ ...payload, ephemeral: true });
|
||||
};
|
||||
this.#handlers?.onInbound({
|
||||
kind: "threads",
|
||||
channelId,
|
||||
guildId: interaction.guildId ?? undefined,
|
||||
author: {
|
||||
id: interaction.user.id,
|
||||
name: interaction.member && "displayName" in interaction.member
|
||||
? String(interaction.member.displayName)
|
||||
: interaction.user.globalName || interaction.user.username,
|
||||
isBot: interaction.user.bot,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
reply,
|
||||
replyPicker,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.#handlers?.onInbound({
|
||||
kind: "clear",
|
||||
channelId,
|
||||
|
|
@ -392,21 +615,163 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
|||
}
|
||||
return channel as unknown as SendableChannel;
|
||||
}
|
||||
|
||||
async #readyClient(): Promise<Client<true>> {
|
||||
const client = this.#client;
|
||||
if (!client) {
|
||||
throw new Error("Discord bridge is not connected");
|
||||
}
|
||||
if (client.isReady()) {
|
||||
return client;
|
||||
}
|
||||
return await new Promise<Client<true>>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Timed out waiting for Discord client ready event"));
|
||||
}, 15_000);
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(readyClient);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #guildIdsForCommandChannels(channelIds: string[]): Promise<string[]> {
|
||||
const client = await this.#readyClient();
|
||||
const guildIds = new Set<string>();
|
||||
for (const channelId of channelIds) {
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
const guildId = guildIdFromChannel(channel);
|
||||
if (guildId) {
|
||||
guildIds.add(guildId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#logger.debug("discord.commands.channelFetchFailed", {
|
||||
channelId,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...guildIds];
|
||||
}
|
||||
}
|
||||
|
||||
function discordBridgeCommands(): ApplicationCommandDataResolvable[] {
|
||||
return [
|
||||
{
|
||||
name: "clear",
|
||||
description: "Delete inactive Codex bridge threads",
|
||||
},
|
||||
{
|
||||
name: "clear-webhooks",
|
||||
description: "Delete webhook-authored messages in this channel",
|
||||
options: [
|
||||
{
|
||||
name: "webhook_url",
|
||||
description: "Optional webhook URL to target a single webhook",
|
||||
type: 3,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
description: "Show Codex gateway status",
|
||||
},
|
||||
{
|
||||
name: "threads",
|
||||
description: "List Codex threads for this workspace",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function commandName(command: ApplicationCommandDataResolvable): string {
|
||||
return typeof command === "object" && command !== null && "name" in command
|
||||
? String(command.name)
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
function threadPickerComponents(
|
||||
picker: DiscordEphemeralPicker,
|
||||
): ActionRowBuilder<ButtonBuilder>[] {
|
||||
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
for (let index = 0; index < picker.options.length; index += 5) {
|
||||
const row = new ActionRowBuilder<ButtonBuilder>();
|
||||
for (const option of picker.options.slice(index, index + 5)) {
|
||||
row.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(threadPickerCustomId(picker.pickerId, option.id))
|
||||
.setLabel(option.label)
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
);
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function threadPickerCustomId(pickerId: string, optionId: string): string {
|
||||
return `${threadPickerCustomIdPrefix}:${pickerId}:${optionId}`;
|
||||
}
|
||||
|
||||
function threadPickerSelectionFromCustomId(
|
||||
customId: string,
|
||||
): { pickerId: string; optionId: string } | undefined {
|
||||
const prefix = `${threadPickerCustomIdPrefix}:`;
|
||||
if (!customId.startsWith(prefix)) {
|
||||
return undefined;
|
||||
}
|
||||
const rest = customId.slice(prefix.length);
|
||||
const [pickerId, optionId, ...extra] = rest.split(":");
|
||||
if (!pickerId || !optionId || extra.length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
return { pickerId, optionId };
|
||||
}
|
||||
|
||||
function emptyAllowedMentions(): Record<string, unknown> {
|
||||
return {
|
||||
parse: [],
|
||||
users: [],
|
||||
roles: [],
|
||||
repliedUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
function guildIdFromChannel(channel: unknown): string | undefined {
|
||||
if (!channel || typeof channel !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = channel as {
|
||||
guildId?: unknown;
|
||||
guild?: { id?: unknown };
|
||||
};
|
||||
if (typeof candidate.guildId === "string") {
|
||||
return candidate.guildId;
|
||||
}
|
||||
if (typeof candidate.guild?.id === "string") {
|
||||
return candidate.guild.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type ThreadCreateOptions = {
|
||||
name: string;
|
||||
autoArchiveDuration?: number;
|
||||
message?: Record<string, unknown>;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type SendableChannel = {
|
||||
type ThreadCreatableChannel = {
|
||||
id: string;
|
||||
send(options: Record<string, unknown>): Promise<{ id?: string }>;
|
||||
sendTyping?: () => Promise<void>;
|
||||
threads?: {
|
||||
create(options: ThreadCreateOptions): Promise<{ id?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
type SendableChannel = ThreadCreatableChannel & {
|
||||
send(options: Record<string, unknown>): Promise<{ id?: string }>;
|
||||
sendTyping?: () => Promise<void>;
|
||||
members?: {
|
||||
add(userId: string): Promise<unknown>;
|
||||
};
|
||||
|
|
@ -426,12 +791,13 @@ type DiscordFetchedMessage = {
|
|||
edit(options: Record<string, unknown>): Promise<unknown>;
|
||||
pinned?: boolean;
|
||||
pin?(): Promise<unknown>;
|
||||
react?(reaction: string): Promise<unknown>;
|
||||
startThread?(options: ThreadCreateOptions): Promise<{ id?: string }>;
|
||||
};
|
||||
|
||||
function getThreadsManager(
|
||||
channel: SendableChannel,
|
||||
): SendableChannel["threads"] | undefined {
|
||||
channel: ThreadCreatableChannel,
|
||||
): ThreadCreatableChannel["threads"] | undefined {
|
||||
return channel.threads;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,18 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { writeStopHookSpoolEvent } from "./stop-hook-spool.ts";
|
||||
import { writeHookSpoolEvent } from "./stop-hook-spool.ts";
|
||||
|
||||
const defaultHookCommand = "codex-discord-bridge hook stop";
|
||||
const defaultHookCommand = "codex-discord-bridge hook event";
|
||||
const defaultBunxPackage = "codex-discord-bridge";
|
||||
const hookStatusMessage = "Recording Discord gateway Stop event";
|
||||
const gatewayHookEvents = [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
] as const;
|
||||
|
||||
export type HookInstallOptions = {
|
||||
command?: string;
|
||||
|
|
@ -29,8 +36,8 @@ export async function handleHookCommand(argv: string[]): Promise<boolean> {
|
|||
return false;
|
||||
}
|
||||
const subcommand = argv[1] ?? "help";
|
||||
if (subcommand === "stop") {
|
||||
await runStopHook();
|
||||
if (subcommand === "event" || subcommand === "stop") {
|
||||
await runHookEvent();
|
||||
return true;
|
||||
}
|
||||
if (subcommand === "install") {
|
||||
|
|
@ -45,17 +52,25 @@ export async function handleHookCommand(argv: string[]): Promise<boolean> {
|
|||
throw new Error(`Unknown hook subcommand: ${subcommand}`);
|
||||
}
|
||||
|
||||
export async function runStopHook(): Promise<void> {
|
||||
export async function runHookEvent(): Promise<void> {
|
||||
let input = "";
|
||||
try {
|
||||
const input = await new Response(Bun.stdin.stream()).text();
|
||||
await writeStopHookSpoolEvent(JSON.parse(input));
|
||||
process.stdout.write(`${JSON.stringify({ continue: true })}\n`);
|
||||
input = await new Response(Bun.stdin.stream()).text();
|
||||
const parsed = JSON.parse(input);
|
||||
const event = await writeHookSpoolEvent(parsed);
|
||||
if (eventSupportsContinueOutput(event.eventName)) {
|
||||
process.stdout.write(`${JSON.stringify({ continue: true })}\n`);
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(`discord gateway stop hook failed: ${errorMessage(error)}\n`);
|
||||
process.stdout.write(`${JSON.stringify({ continue: true })}\n`);
|
||||
process.stderr.write(`discord gateway hook failed: ${errorMessage(error)}\n`);
|
||||
if (eventSupportsContinueOutput(eventNameFromHookInput(input))) {
|
||||
process.stdout.write(`${JSON.stringify({ continue: true })}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const runStopHook = runHookEvent;
|
||||
|
||||
export async function installStopHook(
|
||||
options: HookInstallOptions = {},
|
||||
): Promise<HookInstallResult> {
|
||||
|
|
@ -110,13 +125,15 @@ export function upsertStopHookConfig(
|
|||
): Record<string, unknown> {
|
||||
const config = parseHooksJson(hooksText);
|
||||
const hooks = record(config.hooks);
|
||||
const stopGroups = Array.isArray(hooks.Stop) ? hooks.Stop : [];
|
||||
hooks.Stop = [
|
||||
stopHookGroup(command),
|
||||
...stopGroups
|
||||
.map(removeGatewayStopHookHandlers)
|
||||
.filter((group): group is Record<string, unknown> => group !== undefined),
|
||||
];
|
||||
for (const eventName of gatewayHookEvents) {
|
||||
const groups = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
|
||||
hooks[eventName] = [
|
||||
hookGroup(command),
|
||||
...groups
|
||||
.map(removeGatewayStopHookHandlers)
|
||||
.filter((group): group is Record<string, unknown> => group !== undefined),
|
||||
];
|
||||
}
|
||||
config.hooks = hooks;
|
||||
return config;
|
||||
}
|
||||
|
|
@ -188,14 +205,13 @@ function hookCommand(options: HookInstallOptions): string {
|
|||
return defaultHookCommand;
|
||||
}
|
||||
|
||||
function stopHookGroup(command: string): Record<string, unknown> {
|
||||
function hookGroup(command: string): Record<string, unknown> {
|
||||
return {
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command,
|
||||
timeout: 10,
|
||||
statusMessage: hookStatusMessage,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -216,10 +232,30 @@ function isGatewayStopHookHandler(input: unknown): boolean {
|
|||
const handler = record(input);
|
||||
const command = typeof handler.command === "string" ? handler.command : "";
|
||||
return command.includes("codex-discord-bridge hook stop") ||
|
||||
command.includes("codex-discord-bridge hook event") ||
|
||||
command.includes("codex-discord-gateway-stop-hook") ||
|
||||
command.includes("apps/discord-bridge/src/stop-hook.ts");
|
||||
}
|
||||
|
||||
function eventSupportsContinueOutput(eventName: string): boolean {
|
||||
return eventName === "SessionStart" ||
|
||||
eventName === "UserPromptSubmit" ||
|
||||
eventName === "Stop";
|
||||
}
|
||||
|
||||
function eventNameFromHookInput(input: string): string {
|
||||
try {
|
||||
const parsed = record(JSON.parse(input));
|
||||
return typeof parsed.hook_event_name === "string"
|
||||
? parsed.hook_event_name
|
||||
: typeof parsed.eventName === "string"
|
||||
? parsed.eventName
|
||||
: "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function readTextIfExists(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(filePath, "utf8");
|
||||
|
|
@ -264,14 +300,14 @@ function requiredNext(argv: string[], index: number, flag: string): string {
|
|||
}
|
||||
|
||||
function hookHelpText(): string {
|
||||
return `codex-discord-bridge hook manages the global Codex Stop hook.
|
||||
return `codex-discord-bridge hook manages the global Codex observability hooks.
|
||||
|
||||
Usage:
|
||||
codex-discord-bridge hook install [options]
|
||||
codex-discord-bridge hook stop
|
||||
codex-discord-bridge hook event
|
||||
|
||||
Options:
|
||||
--command <cmd> Hook command to write. Defaults to "codex-discord-bridge hook stop".
|
||||
--command <cmd> Hook command to write. Defaults to "codex-discord-bridge hook event".
|
||||
--bunx Write a bunx command instead of the global binary command.
|
||||
--bunx-package <pkg> Package for bunx --package. Defaults to codex-discord-bridge.
|
||||
--config-path <path> Codex config.toml path.
|
||||
|
|
|
|||
|
|
@ -10,12 +10,17 @@ import type {
|
|||
DiscordBridgeState,
|
||||
DiscordBridgeStateStore,
|
||||
DiscordGatewayDelegation,
|
||||
DiscordGatewayHookEventName,
|
||||
DiscordGatewayObservedThread,
|
||||
DiscordGatewayWorkspaceSurface,
|
||||
DiscordGatewayState,
|
||||
} from "./types.ts";
|
||||
|
||||
const maxProcessedMessageIds = 1000;
|
||||
const maxDeliveries = 500;
|
||||
const maxProcessedStopHookEventIds = 2000;
|
||||
const maxProcessedHookEventIds = 5000;
|
||||
const maxObservedThreads = 1000;
|
||||
|
||||
export class JsonFileStateStore implements DiscordBridgeStateStore {
|
||||
readonly path: string;
|
||||
|
|
@ -81,6 +86,17 @@ export function trimState(state: DiscordBridgeState): void {
|
|||
-maxProcessedStopHookEventIds,
|
||||
);
|
||||
}
|
||||
if (state.gateway?.processedHookEventIds) {
|
||||
state.gateway.processedHookEventIds =
|
||||
state.gateway.processedHookEventIds.slice(-maxProcessedHookEventIds);
|
||||
}
|
||||
if (state.gateway?.observedThreads) {
|
||||
state.gateway.observedThreads = [...state.gateway.observedThreads]
|
||||
.sort((left, right) =>
|
||||
Date.parse(right.lastSeenAt) - Date.parse(left.lastSeenAt)
|
||||
)
|
||||
.slice(0, maxObservedThreads);
|
||||
}
|
||||
}
|
||||
|
||||
function parseState(value: unknown): DiscordBridgeState {
|
||||
|
|
@ -124,9 +140,23 @@ function parseGateway(value: unknown): DiscordGatewayState | undefined {
|
|||
delegations: Array.isArray(value.delegations)
|
||||
? value.delegations.map(parseGatewayDelegation)
|
||||
: [],
|
||||
workspaces: Array.isArray(value.workspaces)
|
||||
? value.workspaces.map(parseGatewayWorkspace)
|
||||
: [],
|
||||
observedThreads: Array.isArray(value.observedThreads)
|
||||
? value.observedThreads.map(parseGatewayObservedThread)
|
||||
: [],
|
||||
pendingWakes: Array.isArray(value.pendingWakes)
|
||||
? value.pendingWakes.map(parseGatewayPendingWake)
|
||||
: [],
|
||||
processedHookEventIds: uniqueStrings([
|
||||
...(Array.isArray(value.processedHookEventIds)
|
||||
? value.processedHookEventIds
|
||||
: []),
|
||||
...(Array.isArray(value.processedStopHookEventIds)
|
||||
? value.processedStopHookEventIds
|
||||
: []),
|
||||
]),
|
||||
processedStopHookEventIds: Array.isArray(value.processedStopHookEventIds)
|
||||
? uniqueStrings(value.processedStopHookEventIds)
|
||||
: [],
|
||||
|
|
@ -156,9 +186,12 @@ function parseGatewayDelegation(value: unknown): DiscordGatewayDelegation {
|
|||
title: requiredString(value.title, "gateway.delegations.title"),
|
||||
status,
|
||||
cwd: optionalString(value.cwd),
|
||||
workspaceKey: optionalString(value.workspaceKey),
|
||||
groupId: optionalString(value.groupId),
|
||||
returnMode: parseReturnMode(value.returnMode),
|
||||
discordDetailThreadId: optionalString(value.discordDetailThreadId),
|
||||
discordTaskThreadId: optionalString(value.discordTaskThreadId),
|
||||
discordWorkspaceThreadId: optionalString(value.discordWorkspaceThreadId),
|
||||
parentDiscordMessageId: optionalString(value.parentDiscordMessageId),
|
||||
lastTurnId: optionalString(value.lastTurnId),
|
||||
lastStatus: optionalString(value.lastStatus),
|
||||
|
|
@ -166,12 +199,34 @@ function parseGatewayDelegation(value: unknown): DiscordGatewayDelegation {
|
|||
completedAt: optionalString(value.completedAt),
|
||||
injectedAt: optionalString(value.injectedAt),
|
||||
mirroredAt: optionalString(value.mirroredAt),
|
||||
taskMirroredAt: optionalString(value.taskMirroredAt),
|
||||
reportedAt: optionalString(value.reportedAt),
|
||||
createdAt: requiredString(value.createdAt, "gateway.delegations.createdAt"),
|
||||
updatedAt: requiredString(value.updatedAt, "gateway.delegations.updatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseGatewayWorkspace(value: unknown): DiscordGatewayWorkspaceSurface {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge gateway workspace");
|
||||
}
|
||||
return {
|
||||
key: requiredString(value.key, "gateway.workspaces.key"),
|
||||
cwd: requiredString(value.cwd, "gateway.workspaces.cwd"),
|
||||
title: requiredString(value.title, "gateway.workspaces.title"),
|
||||
discordThreadId: requiredString(
|
||||
value.discordThreadId,
|
||||
"gateway.workspaces.discordThreadId",
|
||||
),
|
||||
statusMessageId: optionalString(value.statusMessageId),
|
||||
delegationIds: Array.isArray(value.delegationIds)
|
||||
? uniqueStrings(value.delegationIds)
|
||||
: [],
|
||||
createdAt: requiredString(value.createdAt, "gateway.workspaces.createdAt"),
|
||||
updatedAt: requiredString(value.updatedAt, "gateway.workspaces.updatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseGatewayPendingWake(
|
||||
value: unknown,
|
||||
): NonNullable<DiscordGatewayState["pendingWakes"]>[number] {
|
||||
|
|
@ -197,6 +252,57 @@ function parseGatewayPendingWake(
|
|||
};
|
||||
}
|
||||
|
||||
function parseGatewayObservedThread(value: unknown): DiscordGatewayObservedThread {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge gateway observed thread");
|
||||
}
|
||||
return {
|
||||
threadId: requiredString(value.threadId, "gateway.observedThreads.threadId"),
|
||||
title: optionalString(value.title),
|
||||
status: parseObservedThreadStatus(value.status),
|
||||
cwd: optionalString(value.cwd),
|
||||
workspaceKey: optionalString(value.workspaceKey),
|
||||
model: optionalString(value.model),
|
||||
transcriptPath: optionalString(value.transcriptPath),
|
||||
lastTurnId: optionalString(value.lastTurnId),
|
||||
lastHookEventName: parseHookEventName(value.lastHookEventName),
|
||||
source: optionalString(value.source),
|
||||
promptPreview: optionalString(value.promptPreview),
|
||||
assistantPreview: optionalString(value.assistantPreview),
|
||||
toolName: optionalString(value.toolName),
|
||||
toolUseId: optionalString(value.toolUseId),
|
||||
toolInputPreview: optionalString(value.toolInputPreview),
|
||||
toolResponsePreview: optionalString(value.toolResponsePreview),
|
||||
permissionDescription: optionalString(value.permissionDescription),
|
||||
firstSeenAt: requiredString(value.firstSeenAt, "gateway.observedThreads.firstSeenAt"),
|
||||
lastSeenAt: requiredString(value.lastSeenAt, "gateway.observedThreads.lastSeenAt"),
|
||||
updatedAt: requiredString(value.updatedAt, "gateway.observedThreads.updatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseObservedThreadStatus(
|
||||
value: unknown,
|
||||
): DiscordGatewayObservedThread["status"] {
|
||||
return value === "starting" ||
|
||||
value === "active" ||
|
||||
value === "tool" ||
|
||||
value === "waiting" ||
|
||||
value === "idle"
|
||||
? value
|
||||
: "idle";
|
||||
}
|
||||
|
||||
function parseHookEventName(value: unknown): DiscordGatewayHookEventName | undefined {
|
||||
return value === "SessionStart" ||
|
||||
value === "UserPromptSubmit" ||
|
||||
value === "PreToolUse" ||
|
||||
value === "PermissionRequest" ||
|
||||
value === "PostToolUse" ||
|
||||
value === "Stop"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseReturnMode(value: unknown): DiscordGatewayDelegation["returnMode"] {
|
||||
return value === "detached" ||
|
||||
value === "record_only" ||
|
||||
|
|
@ -318,7 +424,11 @@ function optionalNumber(value: unknown): number | undefined {
|
|||
}
|
||||
|
||||
function parseSessionMode(value: unknown): DiscordBridgeSession["mode"] {
|
||||
return value === "new" || value === "resumed" || value === "gateway"
|
||||
return value === "new" ||
|
||||
value === "resumed" ||
|
||||
value === "gateway" ||
|
||||
value === "delegated" ||
|
||||
value === "workspace"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,21 +10,26 @@ import {
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { DiscordGatewayStopHookEvent } from "./types.ts";
|
||||
import type {
|
||||
DiscordGatewayHookEvent,
|
||||
DiscordGatewayHookEventName,
|
||||
} from "./types.ts";
|
||||
|
||||
export type StopHookSpoolDisposition = "processed" | "ignored" | "failed";
|
||||
export type HookEventSpoolDisposition = "processed" | "ignored" | "failed";
|
||||
export type StopHookSpoolDisposition = HookEventSpoolDisposition;
|
||||
|
||||
export type PendingStopHookSpoolFile =
|
||||
export type PendingHookEventSpoolFile =
|
||||
| {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
event: DiscordGatewayStopHookEvent;
|
||||
event: DiscordGatewayHookEvent;
|
||||
}
|
||||
| {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
error: Error;
|
||||
};
|
||||
export type PendingStopHookSpoolFile = PendingHookEventSpoolFile;
|
||||
|
||||
export function defaultStopHookSpoolDir(): string {
|
||||
return path.join(os.homedir(), ".codex", "discord-bridge", "stop-hooks");
|
||||
|
|
@ -37,7 +42,7 @@ export function stopHookSpoolDirFromEnv(
|
|||
}
|
||||
|
||||
export function stopHookSpoolPaths(spoolDir: string): Record<
|
||||
"pending" | StopHookSpoolDisposition,
|
||||
"pending" | HookEventSpoolDisposition,
|
||||
string
|
||||
> {
|
||||
const root = path.resolve(spoolDir);
|
||||
|
|
@ -60,9 +65,19 @@ export async function writeStopHookSpoolEvent(
|
|||
spoolDir?: string;
|
||||
now?: () => Date;
|
||||
} = {},
|
||||
): Promise<DiscordGatewayStopHookEvent> {
|
||||
): Promise<DiscordGatewayHookEvent> {
|
||||
return await writeHookSpoolEvent(input, options);
|
||||
}
|
||||
|
||||
export async function writeHookSpoolEvent(
|
||||
input: unknown,
|
||||
options: {
|
||||
spoolDir?: string;
|
||||
now?: () => Date;
|
||||
} = {},
|
||||
): Promise<DiscordGatewayHookEvent> {
|
||||
const spoolDir = options.spoolDir ?? stopHookSpoolDirFromEnv();
|
||||
const event = stopHookEventFromInput(input, options.now ?? (() => new Date()));
|
||||
const event = hookEventFromInput(input, options.now ?? (() => new Date()));
|
||||
const paths = stopHookSpoolPaths(spoolDir);
|
||||
await mkdir(paths.pending, { recursive: true });
|
||||
const fileName = `${event.id}.json`;
|
||||
|
|
@ -78,13 +93,13 @@ export async function writeStopHookSpoolEvent(
|
|||
|
||||
export async function readPendingStopHookSpoolFiles(
|
||||
spoolDir: string,
|
||||
): Promise<PendingStopHookSpoolFile[]> {
|
||||
): Promise<PendingHookEventSpoolFile[]> {
|
||||
const paths = stopHookSpoolPaths(spoolDir);
|
||||
await ensureStopHookSpool(spoolDir);
|
||||
const fileNames = (await readdir(paths.pending))
|
||||
.filter((fileName) => fileName.endsWith(".json"))
|
||||
.sort();
|
||||
const files: PendingStopHookSpoolFile[] = [];
|
||||
const files: PendingHookEventSpoolFile[] = [];
|
||||
for (const fileName of fileNames) {
|
||||
const filePath = path.join(paths.pending, fileName);
|
||||
try {
|
||||
|
|
@ -92,7 +107,7 @@ export async function readPendingStopHookSpoolFiles(
|
|||
files.push({
|
||||
filePath,
|
||||
fileName,
|
||||
event: parseStopHookSpoolEvent(parsed),
|
||||
event: parseHookSpoolEvent(parsed),
|
||||
});
|
||||
} catch (error) {
|
||||
files.push({
|
||||
|
|
@ -106,9 +121,9 @@ export async function readPendingStopHookSpoolFiles(
|
|||
}
|
||||
|
||||
export async function archiveStopHookSpoolFile(
|
||||
file: Pick<PendingStopHookSpoolFile, "filePath" | "fileName">,
|
||||
file: Pick<PendingHookEventSpoolFile, "filePath" | "fileName">,
|
||||
spoolDir: string,
|
||||
disposition: StopHookSpoolDisposition,
|
||||
disposition: HookEventSpoolDisposition,
|
||||
): Promise<void> {
|
||||
const paths = stopHookSpoolPaths(spoolDir);
|
||||
await mkdir(paths[disposition], { recursive: true });
|
||||
|
|
@ -133,23 +148,29 @@ export async function removeStopHookSpool(spoolDir: string): Promise<void> {
|
|||
await rm(path.resolve(spoolDir), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function stopHookEventFromInput(
|
||||
function hookEventFromInput(
|
||||
input: unknown,
|
||||
now: () => Date,
|
||||
): DiscordGatewayStopHookEvent {
|
||||
): DiscordGatewayHookEvent {
|
||||
const parsed = record(input);
|
||||
const eventName = stringValue(parsed.hook_event_name) ?? stringValue(parsed.eventName);
|
||||
if (eventName && eventName !== "Stop") {
|
||||
if (!isHookEventName(eventName)) {
|
||||
throw new Error(`Unsupported hook event: ${eventName}`);
|
||||
}
|
||||
const sessionId = stringValue(parsed.session_id) ?? stringValue(parsed.sessionId);
|
||||
if (!sessionId) {
|
||||
throw new Error("Stop hook input is missing session_id");
|
||||
throw new Error("Hook input is missing session_id");
|
||||
}
|
||||
const turnId = stringValue(parsed.turn_id) ?? stringValue(parsed.turnId);
|
||||
const transcriptPath =
|
||||
stringValue(parsed.transcript_path) ?? stringValue(parsed.transcriptPath);
|
||||
const cwd = stringValue(parsed.cwd);
|
||||
const model = stringValue(parsed.model);
|
||||
const source = stringValue(parsed.source);
|
||||
const toolName = stringValue(parsed.tool_name) ?? stringValue(parsed.toolName);
|
||||
const toolUseId = stringValue(parsed.tool_use_id) ?? stringValue(parsed.toolUseId);
|
||||
const toolInput = parsed.tool_input ?? parsed.toolInput;
|
||||
const toolResponse = parsed.tool_response ?? parsed.toolResponse;
|
||||
const lastAssistantMessage =
|
||||
nullableString(parsed.last_assistant_message) ??
|
||||
nullableString(parsed.lastAssistantMessage);
|
||||
|
|
@ -159,37 +180,49 @@ function stopHookEventFromInput(
|
|||
: typeof parsed.stopHookActive === "boolean"
|
||||
? parsed.stopHookActive
|
||||
: undefined;
|
||||
const id = stopHookEventId({
|
||||
const id = hookEventId({
|
||||
eventName,
|
||||
sessionId,
|
||||
turnId,
|
||||
transcriptPath,
|
||||
cwd,
|
||||
toolName,
|
||||
toolUseId,
|
||||
source,
|
||||
});
|
||||
return {
|
||||
version: 1,
|
||||
id,
|
||||
eventName: "Stop",
|
||||
eventName,
|
||||
sessionId,
|
||||
turnId,
|
||||
cwd,
|
||||
transcriptPath,
|
||||
lastAssistantMessage,
|
||||
stopHookActive,
|
||||
model,
|
||||
source,
|
||||
promptPreview: previewString(parsed.prompt),
|
||||
toolName,
|
||||
toolUseId,
|
||||
toolInputPreview: previewJson(toolInput),
|
||||
toolResponsePreview: previewJson(toolResponse),
|
||||
permissionDescription: nullableString(record(toolInput).description),
|
||||
lastAssistantMessage: eventName === "Stop" ? lastAssistantMessage : undefined,
|
||||
stopHookActive: eventName === "Stop" ? stopHookActive : undefined,
|
||||
createdAt: now().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseStopHookSpoolEvent(input: unknown): DiscordGatewayStopHookEvent {
|
||||
function parseHookSpoolEvent(input: unknown): DiscordGatewayHookEvent {
|
||||
const parsed = record(input);
|
||||
if (parsed.version !== 1) {
|
||||
throw new Error("Invalid stop hook event version");
|
||||
throw new Error("Invalid hook event version");
|
||||
}
|
||||
const eventName = stringValue(parsed.eventName);
|
||||
const id = stringValue(parsed.id);
|
||||
const sessionId = stringValue(parsed.sessionId);
|
||||
const createdAt = stringValue(parsed.createdAt);
|
||||
if (eventName !== "Stop" || !id || !sessionId || !createdAt) {
|
||||
throw new Error("Invalid stop hook event");
|
||||
if (!isHookEventName(eventName) || !id || !sessionId || !createdAt) {
|
||||
throw new Error("Invalid hook event");
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
|
|
@ -199,6 +232,14 @@ function parseStopHookSpoolEvent(input: unknown): DiscordGatewayStopHookEvent {
|
|||
turnId: stringValue(parsed.turnId),
|
||||
cwd: stringValue(parsed.cwd),
|
||||
transcriptPath: stringValue(parsed.transcriptPath),
|
||||
model: stringValue(parsed.model),
|
||||
source: stringValue(parsed.source),
|
||||
promptPreview: stringValue(parsed.promptPreview),
|
||||
toolName: stringValue(parsed.toolName),
|
||||
toolUseId: stringValue(parsed.toolUseId),
|
||||
toolInputPreview: stringValue(parsed.toolInputPreview),
|
||||
toolResponsePreview: stringValue(parsed.toolResponsePreview),
|
||||
permissionDescription: stringValue(parsed.permissionDescription),
|
||||
lastAssistantMessage: nullableString(parsed.lastAssistantMessage),
|
||||
stopHookActive: typeof parsed.stopHookActive === "boolean"
|
||||
? parsed.stopHookActive
|
||||
|
|
@ -207,21 +248,58 @@ function parseStopHookSpoolEvent(input: unknown): DiscordGatewayStopHookEvent {
|
|||
};
|
||||
}
|
||||
|
||||
function stopHookEventId(input: {
|
||||
function hookEventId(input: {
|
||||
eventName: DiscordGatewayHookEventName;
|
||||
sessionId: string;
|
||||
turnId?: string;
|
||||
transcriptPath?: string;
|
||||
cwd?: string;
|
||||
toolName?: string;
|
||||
toolUseId?: string;
|
||||
source?: string;
|
||||
}): string {
|
||||
const identity = input.turnId
|
||||
? { eventName: "Stop", sessionId: input.sessionId, turnId: input.turnId }
|
||||
: {
|
||||
eventName: "Stop",
|
||||
? {
|
||||
eventName: input.eventName,
|
||||
sessionId: input.sessionId,
|
||||
turnId: input.turnId,
|
||||
toolName: input.toolName,
|
||||
toolUseId: input.toolUseId,
|
||||
}
|
||||
: {
|
||||
eventName: input.eventName,
|
||||
sessionId: input.sessionId,
|
||||
source: input.source,
|
||||
transcriptPath: input.transcriptPath,
|
||||
cwd: input.cwd,
|
||||
};
|
||||
return `stop-${createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 24)}`;
|
||||
const prefix = input.eventName === "Stop" ? "stop" : "hook";
|
||||
return `${prefix}-${createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 24)}`;
|
||||
}
|
||||
|
||||
function isHookEventName(value: unknown): value is DiscordGatewayHookEventName {
|
||||
return value === "SessionStart" ||
|
||||
value === "UserPromptSubmit" ||
|
||||
value === "PreToolUse" ||
|
||||
value === "PermissionRequest" ||
|
||||
value === "PostToolUse" ||
|
||||
value === "Stop";
|
||||
}
|
||||
|
||||
function previewString(value: unknown, maxLength = 500): string | undefined {
|
||||
const parsed = nullableString(value);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed.length <= maxLength ? parsed : `${parsed.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
function previewJson(value: unknown, maxLength = 500): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value);
|
||||
return previewString(text, maxLength);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export type DiscordConsoleOutputMode = "messages" | "none";
|
|||
export type DiscordGatewayConfig = {
|
||||
homeChannelId: string;
|
||||
mainThreadId?: string;
|
||||
workspaceForumChannelId?: string;
|
||||
taskThreadsChannelId?: string;
|
||||
};
|
||||
|
||||
export type DiscordAuthor = {
|
||||
|
|
@ -86,27 +88,97 @@ export type DiscordClearWebhooksInbound = {
|
|||
reply?: (text: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordStatusInbound = {
|
||||
kind: "status";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
author: DiscordAuthor;
|
||||
createdAt: string;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
replyPicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordThreadsInbound = {
|
||||
kind: "threads";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
author: DiscordAuthor;
|
||||
createdAt: string;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
replyPicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordThreadPickerInbound = {
|
||||
kind: "threadPicker";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
pickerId: string;
|
||||
optionId: string;
|
||||
author: DiscordAuthor;
|
||||
createdAt: string;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
update?: (text: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordReactionInbound = {
|
||||
kind: "reaction";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
author: DiscordAuthor;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type DiscordInbound =
|
||||
| DiscordMessageInbound
|
||||
| DiscordThreadStartInbound
|
||||
| DiscordClearInbound
|
||||
| DiscordClearWebhooksInbound;
|
||||
| DiscordClearWebhooksInbound
|
||||
| DiscordStatusInbound
|
||||
| DiscordThreadsInbound
|
||||
| DiscordThreadPickerInbound
|
||||
| DiscordReactionInbound;
|
||||
|
||||
export type DiscordEphemeralPicker = {
|
||||
pickerId: string;
|
||||
text: string;
|
||||
options: DiscordEphemeralPickerOption[];
|
||||
};
|
||||
|
||||
export type DiscordEphemeralPickerOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type DiscordBridgeTransportHandlers = {
|
||||
onInbound(inbound: DiscordInbound): void;
|
||||
};
|
||||
|
||||
export type DiscordBridgeCommandRegistration = {
|
||||
channelIds?: string[];
|
||||
};
|
||||
|
||||
export type DiscordBridgeTransport = {
|
||||
start(handlers: DiscordBridgeTransportHandlers): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
registerCommands(): Promise<void>;
|
||||
registerCommands(options?: DiscordBridgeCommandRegistration): Promise<void>;
|
||||
createForumPost?(
|
||||
channelId: string,
|
||||
name: string,
|
||||
message: string,
|
||||
): Promise<{ threadId: string; messageId?: string }>;
|
||||
createThread(
|
||||
channelId: string,
|
||||
name: string,
|
||||
sourceMessageId?: string,
|
||||
): Promise<string>;
|
||||
sendMessage(channelId: string, text: string): Promise<string[]>;
|
||||
updateMessage?(channelId: string, messageId: string, text: string): Promise<void>;
|
||||
updateMessage?(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
text: string,
|
||||
): Promise<void>;
|
||||
deleteMessage(channelId: string, messageId: string): Promise<void>;
|
||||
deleteWebhookMessages?(
|
||||
channelId: string,
|
||||
|
|
@ -114,6 +186,7 @@ export type DiscordBridgeTransport = {
|
|||
): Promise<{ deleted: number; failed: number }>;
|
||||
deleteThread?(channelId: string): Promise<void>;
|
||||
addThreadMembers?(channelId: string, userIds: string[]): Promise<void>;
|
||||
addReactions?(channelId: string, messageId: string, reactions: string[]): Promise<void>;
|
||||
pinMessage?(channelId: string, messageId: string): Promise<void>;
|
||||
sendTyping(channelId: string): Promise<void>;
|
||||
};
|
||||
|
|
@ -153,7 +226,10 @@ export type DiscordGatewayState = {
|
|||
createdAt?: string;
|
||||
toolsVersion?: number;
|
||||
delegations: DiscordGatewayDelegation[];
|
||||
workspaces?: DiscordGatewayWorkspaceSurface[];
|
||||
observedThreads?: DiscordGatewayObservedThread[];
|
||||
pendingWakes?: DiscordGatewayPendingWake[];
|
||||
processedHookEventIds?: string[];
|
||||
processedStopHookEventIds?: string[];
|
||||
};
|
||||
|
||||
|
|
@ -170,9 +246,12 @@ export type DiscordGatewayDelegation = {
|
|||
title: string;
|
||||
status: "active" | "idle" | "failed" | "complete" | "reported";
|
||||
cwd?: string;
|
||||
workspaceKey?: string;
|
||||
groupId?: string;
|
||||
returnMode?: DiscordGatewayDelegationReturnMode;
|
||||
discordDetailThreadId?: string;
|
||||
discordTaskThreadId?: string;
|
||||
discordWorkspaceThreadId?: string;
|
||||
parentDiscordMessageId?: string;
|
||||
lastTurnId?: string;
|
||||
lastStatus?: string;
|
||||
|
|
@ -180,11 +259,23 @@ export type DiscordGatewayDelegation = {
|
|||
completedAt?: string;
|
||||
injectedAt?: string;
|
||||
mirroredAt?: string;
|
||||
taskMirroredAt?: string;
|
||||
reportedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DiscordGatewayWorkspaceSurface = {
|
||||
key: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
discordThreadId: string;
|
||||
statusMessageId?: string;
|
||||
delegationIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DiscordGatewayPendingWake = {
|
||||
id: string;
|
||||
kind: "delegation" | "group";
|
||||
|
|
@ -195,19 +286,69 @@ export type DiscordGatewayPendingWake = {
|
|||
startedAt?: string;
|
||||
};
|
||||
|
||||
export type DiscordGatewayStopHookEvent = {
|
||||
export type DiscordGatewayHookEventName =
|
||||
| "SessionStart"
|
||||
| "UserPromptSubmit"
|
||||
| "PreToolUse"
|
||||
| "PermissionRequest"
|
||||
| "PostToolUse"
|
||||
| "Stop";
|
||||
|
||||
export type DiscordGatewayHookEvent = {
|
||||
version: 1;
|
||||
id: string;
|
||||
eventName: "Stop";
|
||||
eventName: DiscordGatewayHookEventName;
|
||||
sessionId: string;
|
||||
turnId?: string;
|
||||
cwd?: string;
|
||||
transcriptPath?: string;
|
||||
model?: string;
|
||||
source?: string;
|
||||
promptPreview?: string;
|
||||
toolName?: string;
|
||||
toolUseId?: string;
|
||||
toolInputPreview?: string;
|
||||
toolResponsePreview?: string;
|
||||
permissionDescription?: string;
|
||||
lastAssistantMessage?: string;
|
||||
stopHookActive?: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type DiscordGatewayStopHookEvent = DiscordGatewayHookEvent & {
|
||||
eventName: "Stop";
|
||||
};
|
||||
|
||||
export type DiscordGatewayObservedThreadStatus =
|
||||
| "starting"
|
||||
| "active"
|
||||
| "tool"
|
||||
| "waiting"
|
||||
| "idle";
|
||||
|
||||
export type DiscordGatewayObservedThread = {
|
||||
threadId: string;
|
||||
title?: string;
|
||||
status: DiscordGatewayObservedThreadStatus;
|
||||
cwd?: string;
|
||||
workspaceKey?: string;
|
||||
model?: string;
|
||||
transcriptPath?: string;
|
||||
lastTurnId?: string;
|
||||
lastHookEventName?: DiscordGatewayHookEventName;
|
||||
source?: string;
|
||||
promptPreview?: string;
|
||||
assistantPreview?: string;
|
||||
toolName?: string;
|
||||
toolUseId?: string;
|
||||
toolInputPreview?: string;
|
||||
toolResponsePreview?: string;
|
||||
permissionDescription?: string;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DiscordBridgeSession = {
|
||||
discordThreadId: string;
|
||||
parentChannelId: string;
|
||||
|
|
@ -219,7 +360,7 @@ export type DiscordBridgeSession = {
|
|||
ownerUserId?: string;
|
||||
participantUserIds?: string[];
|
||||
cwd?: string;
|
||||
mode?: "new" | "resumed" | "gateway";
|
||||
mode?: "new" | "resumed" | "gateway" | "delegated" | "workspace";
|
||||
statusMessageId?: string;
|
||||
};
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -196,6 +196,10 @@ describe("parseConfig", () => {
|
|||
"home-channel",
|
||||
"--main-thread-id",
|
||||
"main-thread",
|
||||
"--workspace-forum-channel-id",
|
||||
"workspace-forum",
|
||||
"--task-threads-channel-id",
|
||||
"task-channel",
|
||||
"--flow-backend-url",
|
||||
"http://127.0.0.1:8089",
|
||||
],
|
||||
|
|
@ -206,6 +210,8 @@ describe("parseConfig", () => {
|
|||
{
|
||||
CODEX_DISCORD_GATEWAY_HOME_CHANNEL_ID: "env-home",
|
||||
CODEX_DISCORD_GATEWAY_MAIN_THREAD_ID: "env-thread",
|
||||
CODEX_DISCORD_GATEWAY_WORKSPACE_FORUM_CHANNEL_ID: "env-workspace-forum",
|
||||
CODEX_DISCORD_GATEWAY_TASK_THREADS_CHANNEL_ID: "env-task-channel",
|
||||
CODEX_FLOW_BACKEND_URL: "http://127.0.0.1:8090",
|
||||
},
|
||||
);
|
||||
|
|
@ -216,11 +222,15 @@ describe("parseConfig", () => {
|
|||
expect(fromFlag.config.gateway).toEqual({
|
||||
homeChannelId: "home-channel",
|
||||
mainThreadId: "main-thread",
|
||||
workspaceForumChannelId: "workspace-forum",
|
||||
taskThreadsChannelId: "task-channel",
|
||||
});
|
||||
expect(fromFlag.config.flowBackendUrl).toBe("http://127.0.0.1:8089");
|
||||
expect(fromEnv.config.gateway).toEqual({
|
||||
homeChannelId: "env-home",
|
||||
mainThreadId: "env-thread",
|
||||
workspaceForumChannelId: "env-workspace-forum",
|
||||
taskThreadsChannelId: "env-task-channel",
|
||||
});
|
||||
expect(fromEnv.config.flowBackendUrl).toBe("http://127.0.0.1:8090");
|
||||
}
|
||||
|
|
@ -242,6 +252,48 @@ describe("parseConfig", () => {
|
|||
).toThrow("Cannot set a gateway main thread without a gateway home channel.");
|
||||
});
|
||||
|
||||
test("rejects partial gateway workbench channel configuration", () => {
|
||||
expect(() =>
|
||||
parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--home-channel-id",
|
||||
"home-channel",
|
||||
"--workspace-forum-channel-id",
|
||||
"workspace-forum",
|
||||
],
|
||||
{},
|
||||
)
|
||||
).toThrow(
|
||||
"Discord workbench requires both workspace forum and task threads channels.",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects gateway workbench channels that are not separate", () => {
|
||||
expect(() =>
|
||||
parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--home-channel-id",
|
||||
"home-channel",
|
||||
"--workspace-forum-channel-id",
|
||||
"workspace-forum",
|
||||
"--task-threads-channel-id",
|
||||
"home-channel",
|
||||
],
|
||||
{},
|
||||
)
|
||||
).toThrow(
|
||||
"Discord workbench channels must be separate from the gateway home channel and each other.",
|
||||
);
|
||||
});
|
||||
|
||||
test("can force a local app-server even when workspace URL env is set", () => {
|
||||
const parsed = parseConfig(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ describe("discord gateway hook CLI", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("upserts package-bin Stop hook while preserving unrelated hooks", () => {
|
||||
test("upserts package-bin observability hooks while preserving unrelated hooks", () => {
|
||||
const updated = upsertStopHookConfig(
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
|
|
@ -46,25 +46,66 @@ describe("discord gateway hook CLI", () => {
|
|||
],
|
||||
},
|
||||
}),
|
||||
"codex-discord-bridge hook stop",
|
||||
"codex-discord-bridge hook event",
|
||||
);
|
||||
|
||||
expect(updated).toEqual({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "codex-discord-bridge hook event",
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
matcher: "Bash",
|
||||
hooks: [{ type: "command", command: "echo pre" }],
|
||||
},
|
||||
],
|
||||
PermissionRequest: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "codex-discord-bridge hook event",
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
PostToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "codex-discord-bridge hook event",
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
SessionStart: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "codex-discord-bridge hook event",
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "codex-discord-bridge hook stop",
|
||||
command: "codex-discord-bridge hook event",
|
||||
timeout: 10,
|
||||
statusMessage: "Recording Discord gateway Stop event",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -72,6 +113,17 @@ describe("discord gateway hook CLI", () => {
|
|||
hooks: [{ type: "command", command: "echo other-stop" }],
|
||||
},
|
||||
],
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "codex-discord-bridge hook event",
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -91,7 +143,7 @@ describe("discord gateway hook CLI", () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
command:
|
||||
"bunx --package @peezy.tech/codex-flows codex-discord-bridge hook stop",
|
||||
"bunx --package @peezy.tech/codex-flows codex-discord-bridge hook event",
|
||||
configPath,
|
||||
hooksPath,
|
||||
dryRun: false,
|
||||
|
|
@ -102,12 +154,22 @@ describe("discord gateway hook CLI", () => {
|
|||
expect(JSON.parse(await readFile(hooksPath, "utf8"))).toEqual(
|
||||
expect.objectContaining({
|
||||
hooks: expect.objectContaining({
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
hooks: [
|
||||
expect.objectContaining({
|
||||
command:
|
||||
"bunx --package @peezy.tech/codex-flows codex-discord-bridge hook event",
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
expect.objectContaining({
|
||||
command:
|
||||
"bunx --package @peezy.tech/codex-flows codex-discord-bridge hook stop",
|
||||
"bunx --package @peezy.tech/codex-flows codex-discord-bridge hook event",
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -27,12 +27,46 @@ describe("JsonFileStateStore", () => {
|
|||
title: "Patchbay webhook work",
|
||||
status: "active",
|
||||
cwd: "/workspace/patchbay",
|
||||
workspaceKey: "workspace-patchbay",
|
||||
discordDetailThreadId: "discord-detail-thread",
|
||||
discordTaskThreadId: "discord-task-thread",
|
||||
discordWorkspaceThreadId: "discord-workspace-thread",
|
||||
parentDiscordMessageId: "message-parent",
|
||||
taskMirroredAt: "2026-05-11T00:00:02.500Z",
|
||||
createdAt: "2026-05-11T00:00:01.000Z",
|
||||
updatedAt: "2026-05-11T00:00:02.000Z",
|
||||
},
|
||||
],
|
||||
workspaces: [
|
||||
{
|
||||
key: "workspace-patchbay",
|
||||
cwd: "/workspace/patchbay",
|
||||
title: "patchbay",
|
||||
discordThreadId: "discord-workspace-thread",
|
||||
statusMessageId: "message-workspace-status",
|
||||
delegationIds: ["delegation-1", "", "delegation-1"],
|
||||
createdAt: "2026-05-11T00:00:00.500Z",
|
||||
updatedAt: "2026-05-11T00:00:02.500Z",
|
||||
},
|
||||
],
|
||||
observedThreads: [
|
||||
{
|
||||
threadId: "codex-observed-thread",
|
||||
title: "Observed work",
|
||||
status: "waiting",
|
||||
cwd: "/workspace/patchbay",
|
||||
workspaceKey: "workspace-patchbay",
|
||||
model: "gpt-test",
|
||||
transcriptPath: "/tmp/observed.jsonl",
|
||||
lastTurnId: "turn-observed",
|
||||
lastHookEventName: "PermissionRequest",
|
||||
promptPreview: "Observed prompt",
|
||||
permissionDescription: "Needs approval",
|
||||
firstSeenAt: "2026-05-11T00:00:01.000Z",
|
||||
lastSeenAt: "2026-05-11T00:00:04.000Z",
|
||||
updatedAt: "2026-05-11T00:00:04.000Z",
|
||||
},
|
||||
],
|
||||
pendingWakes: [
|
||||
{
|
||||
id: "wake-1",
|
||||
|
|
@ -49,6 +83,7 @@ describe("JsonFileStateStore", () => {
|
|||
"stop-1",
|
||||
"stop-2",
|
||||
],
|
||||
processedHookEventIds: ["hook-1", "", "hook-1"],
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
|
|
@ -111,12 +146,52 @@ describe("JsonFileStateStore", () => {
|
|||
title: "Patchbay webhook work",
|
||||
status: "active",
|
||||
cwd: "/workspace/patchbay",
|
||||
workspaceKey: "workspace-patchbay",
|
||||
discordDetailThreadId: "discord-detail-thread",
|
||||
discordTaskThreadId: "discord-task-thread",
|
||||
discordWorkspaceThreadId: "discord-workspace-thread",
|
||||
parentDiscordMessageId: "message-parent",
|
||||
taskMirroredAt: "2026-05-11T00:00:02.500Z",
|
||||
createdAt: "2026-05-11T00:00:01.000Z",
|
||||
updatedAt: "2026-05-11T00:00:02.000Z",
|
||||
},
|
||||
],
|
||||
workspaces: [
|
||||
{
|
||||
key: "workspace-patchbay",
|
||||
cwd: "/workspace/patchbay",
|
||||
title: "patchbay",
|
||||
discordThreadId: "discord-workspace-thread",
|
||||
statusMessageId: "message-workspace-status",
|
||||
delegationIds: ["delegation-1"],
|
||||
createdAt: "2026-05-11T00:00:00.500Z",
|
||||
updatedAt: "2026-05-11T00:00:02.500Z",
|
||||
},
|
||||
],
|
||||
observedThreads: [
|
||||
{
|
||||
threadId: "codex-observed-thread",
|
||||
title: "Observed work",
|
||||
status: "waiting",
|
||||
cwd: "/workspace/patchbay",
|
||||
workspaceKey: "workspace-patchbay",
|
||||
model: "gpt-test",
|
||||
transcriptPath: "/tmp/observed.jsonl",
|
||||
lastTurnId: "turn-observed",
|
||||
lastHookEventName: "PermissionRequest",
|
||||
source: undefined,
|
||||
promptPreview: "Observed prompt",
|
||||
assistantPreview: undefined,
|
||||
toolName: undefined,
|
||||
toolUseId: undefined,
|
||||
toolInputPreview: undefined,
|
||||
toolResponsePreview: undefined,
|
||||
permissionDescription: "Needs approval",
|
||||
firstSeenAt: "2026-05-11T00:00:01.000Z",
|
||||
lastSeenAt: "2026-05-11T00:00:04.000Z",
|
||||
updatedAt: "2026-05-11T00:00:04.000Z",
|
||||
},
|
||||
],
|
||||
pendingWakes: [
|
||||
{
|
||||
id: "wake-1",
|
||||
|
|
@ -127,6 +202,7 @@ describe("JsonFileStateStore", () => {
|
|||
createdAt: "2026-05-11T00:00:03.000Z",
|
||||
},
|
||||
],
|
||||
processedHookEventIds: ["hook-1", "stop-1", "stop-2"],
|
||||
processedStopHookEventIds: ["stop-1", "stop-2"],
|
||||
});
|
||||
expect(state.sessions).toHaveLength(2);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,52 @@ describe("stop hook spool", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("writes passive lifecycle hook events with previews", async () => {
|
||||
const spoolDir = await mkdtemp(path.join(os.tmpdir(), "hook-spool-"));
|
||||
try {
|
||||
const event = await writeStopHookSpoolEvent(
|
||||
{
|
||||
hook_event_name: "UserPromptSubmit",
|
||||
session_id: "session-observed",
|
||||
turn_id: "turn-observed",
|
||||
cwd: "/workspace/observed",
|
||||
transcript_path: "/tmp/session-observed.jsonl",
|
||||
model: "gpt-test",
|
||||
prompt: "Inspect the observed workspace without routing through Discord.",
|
||||
},
|
||||
{
|
||||
spoolDir,
|
||||
now: () => new Date("2026-05-14T12:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(event).toEqual(
|
||||
expect.objectContaining({
|
||||
eventName: "UserPromptSubmit",
|
||||
sessionId: "session-observed",
|
||||
turnId: "turn-observed",
|
||||
cwd: "/workspace/observed",
|
||||
model: "gpt-test",
|
||||
promptPreview:
|
||||
"Inspect the observed workspace without routing through Discord.",
|
||||
}),
|
||||
);
|
||||
const pending = await readPendingStopHookSpoolFiles(spoolDir);
|
||||
expect(pending[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
id: event.id,
|
||||
eventName: "UserPromptSubmit",
|
||||
promptPreview:
|
||||
"Inspect the observed workspace without routing through Discord.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await rm(spoolDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("archives processed files out of pending", async () => {
|
||||
const spoolDir = await mkdtemp(path.join(os.tmpdir(), "stop-hook-spool-"));
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue