forum mode

This commit is contained in:
matamune 2026-05-15 00:57:28 +00:00
parent 84400f8689
commit 08263e60ec
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
13 changed files with 3394 additions and 178 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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