From d84aabf642c3fe6dfe2cc06f9d3aafdf4973af85 Mon Sep 17 00:00:00 2001 From: clawSean Date: Sun, 5 Jul 2026 19:11:58 -0700 Subject: [PATCH] feat: add channel pairing request hook (#97733) * feat: add channel pairing request hook * fix(plugin-sdk): keep pairing hook types internal * docs: regenerate docs map * docs: regenerate docs map * docs: regenerate docs map * fix(plugin-sdk): keep pairing hook types internal * fix(plugin-sdk): keep pairing hook types internal * docs: refresh plugin hook docs * docs: refresh plugin hook docs * docs: refresh plugin hook docs --------- Co-authored-by: clawSean <260045960+clawSean@users.noreply.github.com> Co-authored-by: Omar Shahine --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/docs_map.md | 1 + docs/plugins/hooks.md | 41 +++++++-- .../src/monitor/agent-components-dm-auth.ts | 1 + .../src/monitor/dm-command-decision.ts | 1 + .../imessage/src/monitor/monitor-provider.ts | 1 + extensions/line/src/bot-handlers.ts | 1 + .../signal/src/monitor/access-policy.ts | 1 + extensions/slack/src/monitor/dm-auth.ts | 1 + extensions/sms/src/inbound.ts | 1 + extensions/telegram/src/dm-access.ts | 1 + .../whatsapp/src/inbound/access-control.ts | 1 + src/pairing/pairing-challenge.test.ts | 86 ++++++++++++++++++- src/pairing/pairing-challenge.ts | 39 +++++++++ src/plugin-sdk/channel-pairing.test.ts | 83 +++++++++++++++++- src/plugin-sdk/channel-pairing.ts | 11 ++- src/plugins/hook-types.ts | 25 ++++++ src/plugins/hooks.ts | 14 +++ 18 files changed, 298 insertions(+), 15 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 2094b716b5cf..dfb9246b39d3 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -ff9754cd53ed77f33d8b4509736286b690675945b90b9a923ee7d4270f504fdd plugin-sdk-api-baseline.json -25bc1c31b2f1b3215db392296f5e8b8b41495016428f15eb879fe1fb0df84836 plugin-sdk-api-baseline.jsonl +ad3c811b2b11ce21cbad35a1420892789ab2ea9bc3359b246caab7b465fef848 plugin-sdk-api-baseline.json +2f4f477911da2a08116b684113aa136ca1d1eebdeb5e2136e191d01cc1c4b07a plugin-sdk-api-baseline.jsonl diff --git a/docs/docs_map.md b/docs/docs_map.md index 91b8ca3c05cc..2ca5f3622a03 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -5495,6 +5495,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H2: Quick start - H2: Hook catalog + - H3: Channel pairing requests - H2: Debug runtime hooks - H2: Tool call policy - H3: Exec environment hook diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 1c843cb69015..bd8b741a5aa9 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -128,15 +128,16 @@ observation-only. **Messages and delivery** -| Hook | Purpose | -| --------------------------- | ----------------------------------------------------------------- | -| **`inbound_claim`** | Claim an inbound message before agent routing (synthetic replies) | -| `message_received` | Observe inbound content, sender, thread, and metadata | -| **`message_sending`** | Rewrite outbound content or cancel delivery | -| **`reply_payload_sending`** | Mutate or cancel normalized reply payloads before delivery | -| `message_sent` | Observe outbound delivery success or failure | -| **`before_dispatch`** | Inspect or rewrite an outbound dispatch before channel handoff | -| **`reply_dispatch`** | Participate in the final reply-dispatch pipeline | +| Hook | Purpose | +| ------------------------------- | ----------------------------------------------------------------- | +| **`inbound_claim`** | Claim an inbound message before agent routing (synthetic replies) | +| **`channel_pairing_requested`** | Observe newly created DM pairing requests | +| `message_received` | Observe inbound content, sender, thread, and metadata | +| **`message_sending`** | Rewrite outbound content or cancel delivery | +| **`reply_payload_sending`** | Mutate or cancel normalized reply payloads before delivery | +| `message_sent` | Observe outbound delivery success or failure | +| **`before_dispatch`** | Inspect or rewrite an outbound dispatch before channel handoff | +| **`reply_dispatch`** | Participate in the final reply-dispatch pipeline | **Sessions and compaction** @@ -163,6 +164,28 @@ observation-only. | `cron_changed` | Observe Gateway-owned cron lifecycle changes (added, updated, removed, started, finished, scheduled) | | **`before_install`** | Inspect staged skill or plugin install material from a loaded plugin runtime | +### Channel pairing requests + +Use `channel_pairing_requested` when a plugin needs to notify an operator or +write an audit record after an unpaired DM sender creates a pending pairing +request. The hook is dispatched when the request is created; channel delivery of +the pairing reply is not delayed by slow or failing hook handlers. + +```typescript +api.on("channel_pairing_requested", async (event) => { + await notifyOperator({ + text: `New ${event.channel} pairing request from ${event.senderId}: ${event.code}`, + }); +}); +``` + +The hook is observation-only. It does not approve, reject, suppress, or rewrite +the pairing reply. The payload includes the channel, optional `accountId`, +channel-scoped `senderId`, pairing `code`, and channel metadata. Treat the +pairing code as a live single-use approval credential and deliver it only to a +trusted operator sink. Treat `metadata` as untrusted sender-supplied identity +text. The hook does not include the inbound message body or media. + ## Debug runtime hooks Use `before_model_resolve` to switch provider or model for an agent turn - it diff --git a/extensions/discord/src/monitor/agent-components-dm-auth.ts b/extensions/discord/src/monitor/agent-components-dm-auth.ts index 451e06c81d49..924abae23d07 100644 --- a/extensions/discord/src/monitor/agent-components-dm-auth.ts +++ b/extensions/discord/src/monitor/agent-components-dm-auth.ts @@ -67,6 +67,7 @@ async function ensureDmComponentAuthorized(params: { } const pairingResult = await createChannelPairingChallengeIssuer({ channel: "discord", + accountId: ctx.accountId, upsertPairingRequest: async ({ id, meta }) => { return await upsertChannelPairingRequest({ channel: "discord", diff --git a/extensions/discord/src/monitor/dm-command-decision.ts b/extensions/discord/src/monitor/dm-command-decision.ts index 87af1c4c018c..4e72132fd263 100644 --- a/extensions/discord/src/monitor/dm-command-decision.ts +++ b/extensions/discord/src/monitor/dm-command-decision.ts @@ -23,6 +23,7 @@ export async function handleDiscordDmCommandDecision(params: { const upsertPairingRequest = params.upsertPairingRequest ?? upsertChannelPairingRequest; const result = await createChannelPairingChallengeIssuer({ channel: "discord", + accountId: params.accountId, upsertPairingRequest: async ({ id, meta }) => await upsertPairingRequest({ channel: "discord", diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index b84187ced506..e4184cc6b939 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -1089,6 +1089,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P } await createChannelPairingChallengeIssuer({ channel: "imessage", + accountId: accountInfo.accountId, upsertPairingRequest: async ({ id, meta }) => await upsertChannelPairingRequest({ channel: "imessage", diff --git a/extensions/line/src/bot-handlers.ts b/extensions/line/src/bot-handlers.ts index 45a2f1d08594..7277652cc717 100644 --- a/extensions/line/src/bot-handlers.ts +++ b/extensions/line/src/bot-handlers.ts @@ -190,6 +190,7 @@ async function sendLinePairingReply(params: { })(); await createChannelPairingChallengeIssuer({ channel: "line", + accountId: context.account.accountId, upsertPairingRequest: async ({ id, meta }) => await upsertChannelPairingRequest({ channel: "line", diff --git a/extensions/signal/src/monitor/access-policy.ts b/extensions/signal/src/monitor/access-policy.ts index 8db58a440442..f5431f8fbfc7 100644 --- a/extensions/signal/src/monitor/access-policy.ts +++ b/extensions/signal/src/monitor/access-policy.ts @@ -178,6 +178,7 @@ export async function handleSignalDirectMessageAccess(params: { if (params.dmPolicy === "pairing") { await createChannelPairingChallengeIssuer({ channel: "signal", + accountId: params.accountId, upsertPairingRequest: async ({ id, meta }) => await upsertChannelPairingRequest({ channel: "signal", diff --git a/extensions/slack/src/monitor/dm-auth.ts b/extensions/slack/src/monitor/dm-auth.ts index c2092ed3d45d..898a86849246 100644 --- a/extensions/slack/src/monitor/dm-auth.ts +++ b/extensions/slack/src/monitor/dm-auth.ts @@ -42,6 +42,7 @@ export async function authorizeSlackDirectMessage(params: { if (params.ctx.dmPolicy === "pairing") { await createChannelPairingChallengeIssuer({ channel: "slack", + accountId: params.accountId, upsertPairingRequest: async ({ id, meta }) => await upsertChannelPairingRequest({ channel: "slack", diff --git a/extensions/sms/src/inbound.ts b/extensions/sms/src/inbound.ts index a2b24a241ad3..a0268943f4da 100644 --- a/extensions/sms/src/inbound.ts +++ b/extensions/sms/src/inbound.ts @@ -57,6 +57,7 @@ async function issueSmsPairingChallenge(params: { }) { const issueChallenge = createChannelPairingChallengeIssuer({ channel: CHANNEL_ID, + accountId: params.account.accountId, upsertPairingRequest: async (input) => await params.channelRuntime.pairing.upsertPairingRequest({ channel: CHANNEL_ID, diff --git a/extensions/telegram/src/dm-access.ts b/extensions/telegram/src/dm-access.ts index 517f7db0988e..a49b0193c3e9 100644 --- a/extensions/telegram/src/dm-access.ts +++ b/extensions/telegram/src/dm-access.ts @@ -127,6 +127,7 @@ export async function enforceTelegramDmAccess(params: { const telegramUserId = sender.userId ?? sender.candidateId; await createChannelPairingChallengeIssuer({ channel: "telegram", + accountId, upsertPairingRequest: async ({ id, meta }) => await (upsertPairingRequest ?? upsertChannelPairingRequest)({ channel: "telegram", diff --git a/extensions/whatsapp/src/inbound/access-control.ts b/extensions/whatsapp/src/inbound/access-control.ts index 772d7128a19a..aeedfec57837 100644 --- a/extensions/whatsapp/src/inbound/access-control.ts +++ b/extensions/whatsapp/src/inbound/access-control.ts @@ -141,6 +141,7 @@ export async function checkInboundAccessControl(params: { } else { await createChannelPairingChallengeIssuer({ channel: "whatsapp", + accountId: policy.account.accountId, upsertPairingRequest: async ({ id, meta }) => await upsertChannelPairingRequest({ channel: "whatsapp", diff --git a/src/pairing/pairing-challenge.test.ts b/src/pairing/pairing-challenge.test.ts index 6ebd334578a1..9854af0edc25 100644 --- a/src/pairing/pairing-challenge.test.ts +++ b/src/pairing/pairing-challenge.test.ts @@ -1,8 +1,17 @@ // Tests pairing challenge creation, validation, and reply formatting. -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../plugins/hook-runner-global.js"; +import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js"; import { issuePairingChallenge } from "./pairing-challenge.js"; describe("issuePairingChallenge", () => { + afterEach(() => { + resetGlobalHookRunner(); + }); + function createBaseChallengeParams() { return { channel: "forum", @@ -156,4 +165,79 @@ describe("issuePairingChallenge", () => { ] as const)("$name", async ({ setup }) => { await expectIssuedChallengeCase(setup()); }); + + it("fires channel_pairing_requested only for newly created requests", async () => { + const handler = vi.fn(async () => {}); + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "channel_pairing_requested", + handler, + }, + ]), + ); + + await issuePairingChallenge({ + ...createBaseChallengeParams(), + accountId: "alerts", + meta: { username: "alice" }, + upsertPairingRequest: async () => ({ code: "HOOK1234", created: true }), + sendPairingReply: async () => {}, + }); + await issuePairingChallenge({ + ...createBaseChallengeParams(), + accountId: "alerts", + upsertPairingRequest: async () => ({ code: "EXISTS12", created: false }), + sendPairingReply: async () => {}, + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + { + channel: "forum", + accountId: "alerts", + senderId: "123", + code: "HOOK1234", + metadata: { username: "alice" }, + }, + { + channelId: "forum", + accountId: "alerts", + senderId: "123", + }, + ); + }); + + it("does not block pairing replies when pairing-request hooks fail or stall", async () => { + const throwingHook = vi.fn(() => { + throw new Error("notification failed"); + }); + const stallingHook = vi.fn(() => new Promise(() => {})); + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "channel_pairing_requested", + handler: throwingHook, + pluginId: "throwing", + }, + { + hookName: "channel_pairing_requested", + handler: stallingHook, + pluginId: "stalling", + }, + ]), + ); + const sendPairingReply = vi.fn(async () => {}); + + const result = await issuePairingChallenge({ + ...createBaseChallengeParams(), + upsertPairingRequest: async () => ({ code: "FAST1234", created: true }), + sendPairingReply, + }); + + expect(result).toEqual({ created: true, code: "FAST1234" }); + expect(throwingHook).toHaveBeenCalledTimes(1); + expect(stallingHook).toHaveBeenCalledTimes(1); + expect(sendPairingReply).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/pairing/pairing-challenge.ts b/src/pairing/pairing-challenge.ts index 782bad59aa7c..06d5c67621f9 100644 --- a/src/pairing/pairing-challenge.ts +++ b/src/pairing/pairing-challenge.ts @@ -1,10 +1,13 @@ // Builds and validates channel pairing challenges for first-time setup. +import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import { normalizeAccountId } from "../routing/account-id.js"; import { buildPairingReply } from "./pairing-messages.js"; type PairingMeta = Record; export type PairingChallengeParams = { channel: string; + accountId?: string; senderId: string; senderIdLine: string; meta?: PairingMeta; @@ -18,6 +21,33 @@ export type PairingChallengeParams = { onReplyError?: (err: unknown) => void; }; +async function runPairingRequestedHook(params: { + channel: string; + accountId?: string; + senderId: string; + code: string; + meta?: PairingMeta; +}): Promise { + const hookRunner = getGlobalHookRunner(); + if (!hookRunner?.hasHooks("channel_pairing_requested")) { + return; + } + await hookRunner.runChannelPairingRequested( + { + channel: params.channel, + accountId: params.accountId, + senderId: params.senderId, + code: params.code, + metadata: params.meta, + }, + { + channelId: params.channel, + accountId: params.accountId, + senderId: params.senderId, + }, + ); +} + /** * Shared pairing challenge issuance for DM pairing policy pathways. * Ensures every channel follows the same create-if-missing + reply flow. @@ -33,6 +63,15 @@ export async function issuePairingChallenge( return { created: false }; } params.onCreated?.({ code }); + const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined; + // Notification/audit hooks must not delay the pairing-code reply. + void runPairingRequestedHook({ + channel: params.channel, + accountId, + senderId: params.senderId, + code, + meta: params.meta, + }).catch(() => undefined); const replyText = params.buildReplyText?.({ code, senderIdLine: params.senderIdLine }) ?? buildPairingReply({ diff --git a/src/plugin-sdk/channel-pairing.test.ts b/src/plugin-sdk/channel-pairing.test.ts index 4815bff00587..a535b4e7d579 100644 --- a/src/plugin-sdk/channel-pairing.test.ts +++ b/src/plugin-sdk/channel-pairing.test.ts @@ -1,7 +1,12 @@ /** * Tests channel pairing helpers and pairing reply behavior. */ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../plugins/hook-runner-global.js"; +import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import { createChannelPairingChallengeIssuer, @@ -18,6 +23,10 @@ function createReplyCollector() { }; } +afterEach(() => { + resetGlobalHookRunner(); +}); + describe("createChannelPairingController", () => { it("scopes store access and issues pairing challenges through the scoped store", async () => { const readAllowFromStore = vi.fn(async () => ["alice"]); @@ -58,6 +67,47 @@ describe("createChannelPairingController", () => { expect(sendPairingReply).toHaveBeenCalledTimes(1); expect(replies[0]).toContain("123456"); }); + + it("passes the scoped account id to channel_pairing_requested hooks", async () => { + const handler = vi.fn(async () => {}); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "channel_pairing_requested", handler }]), + ); + const runtime = { + channel: { + pairing: { + readAllowFromStore: vi.fn(async () => []), + upsertPairingRequest: vi.fn(async () => ({ code: "ACCT1234", created: true })), + }, + }, + } as unknown as PluginRuntime; + + const pairing = createChannelPairingController({ + core: runtime, + channel: "googlechat", + accountId: "Primary", + }); + + await pairing.issueChallenge({ + senderId: "user-1", + senderIdLine: "Your id: user-1", + sendPairingReply: async () => {}, + }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "googlechat", + accountId: "primary", + senderId: "user-1", + code: "ACCT1234", + }), + expect.objectContaining({ + channelId: "googlechat", + accountId: "primary", + senderId: "user-1", + }), + ); + }); }); describe("createChannelPairingChallengeIssuer", () => { @@ -81,4 +131,35 @@ describe("createChannelPairingChallengeIssuer", () => { }); expect(replies[0]).toContain("654321"); }); + + it("normalizes account ids before sending channel_pairing_requested hooks", async () => { + const handler = vi.fn(async () => {}); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "channel_pairing_requested", handler }]), + ); + const issueChallenge = createChannelPairingChallengeIssuer({ + channel: "quietchat", + accountId: "Alerts", + upsertPairingRequest: vi.fn(async () => ({ code: "NORM1234", created: true })), + }); + + await issueChallenge({ + senderId: "user-3", + senderIdLine: "Your id: user-3", + sendPairingReply: async () => {}, + }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "quietchat", + accountId: "alerts", + senderId: "user-3", + }), + expect.objectContaining({ + channelId: "quietchat", + accountId: "alerts", + senderId: "user-3", + }), + ); + }); }); diff --git a/src/plugin-sdk/channel-pairing.ts b/src/plugin-sdk/channel-pairing.ts index cd7a0649ed87..534caa515f52 100644 --- a/src/plugin-sdk/channel-pairing.ts +++ b/src/plugin-sdk/channel-pairing.ts @@ -20,7 +20,10 @@ type ScopedPairingAccess = ReturnType; export type ChannelPairingController = ScopedPairingAccess & { /** Issue a pairing challenge using the controller's channel and scoped store writer. */ issueChallenge: ( - params: Omit[0], "channel" | "upsertPairingRequest">, + params: Omit< + Parameters[0], + "channel" | "accountId" | "upsertPairingRequest" + >, ) => ReturnType; }; @@ -28,6 +31,8 @@ export type ChannelPairingController = ScopedPairingAccess & { export function createChannelPairingChallengeIssuer(params: { /** Channel id attached to every challenge issued by the returned helper. */ channel: ChannelId; + /** Optional channel account id attached to pairing-request hook payloads. */ + accountId?: string; /** Store writer that persists pending pairing requests for the bound channel. */ upsertPairingRequest: Parameters[0]["upsertPairingRequest"]; }) { @@ -35,11 +40,12 @@ export function createChannelPairingChallengeIssuer(params: { /** Challenge details supplied at message handling time. */ challenge: Omit< Parameters[0], - "channel" | "upsertPairingRequest" + "channel" | "accountId" | "upsertPairingRequest" >, ) => issuePairingChallenge({ channel: params.channel, + accountId: params.accountId, upsertPairingRequest: params.upsertPairingRequest, ...challenge, }); @@ -59,6 +65,7 @@ export function createChannelPairingController(params: { ...access, issueChallenge: createChannelPairingChallengeIssuer({ channel: params.channel, + accountId: access.accountId, upsertPairingRequest: access.upsertPairingRequest, }), }; diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 4d3875192250..639046016b65 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -94,6 +94,7 @@ export type PluginHookName = | "after_compaction" | "before_reset" | "inbound_claim" + | "channel_pairing_requested" | "message_received" | "message_sending" | "reply_payload_sending" @@ -141,6 +142,7 @@ export const PLUGIN_HOOK_NAMES = [ "after_compaction", "before_reset", "inbound_claim", + "channel_pairing_requested", "message_received", "message_sending", "reply_payload_sending", @@ -180,6 +182,25 @@ export type PluginHookDeprecation = { removeAfter?: string; }; +type PluginHookChannelPairingRequestedEvent = { + /** Channel that created the pending pairing request. */ + channel: string; + /** Provider account ID for multi-account channel setups. */ + accountId?: string; + /** Channel-scoped sender ID awaiting operator approval. */ + senderId: string; + /** Short-lived code accepted by `openclaw pairing approve`. */ + code: string; + /** Sender-supplied channel metadata for operator notification/audit. Treat as untrusted. */ + metadata?: Record; +}; + +type PluginHookChannelPairingContext = { + channelId: string; + accountId?: string; + senderId: string; +}; + export const DEPRECATED_PLUGIN_HOOKS = { subagent_spawning: { replacement: "`subagent_spawned` for observation; core session bindings for routing", @@ -1144,6 +1165,10 @@ export type PluginHookHandlerMap = { event: PluginHookInboundClaimEvent, ctx: PluginHookInboundClaimContext, ) => Promise | PluginHookInboundClaimResult | void; + channel_pairing_requested: ( + event: PluginHookChannelPairingRequestedEvent, + ctx: PluginHookChannelPairingContext, + ) => Promise | void; before_dispatch: ( event: PluginHookBeforeDispatchEvent, ctx: PluginHookBeforeDispatchContext, diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 57abb8ce3ffa..7668200fc158 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -30,6 +30,7 @@ import type { PluginHookBeforeDispatchContext, PluginHookBeforeDispatchEvent, PluginHookBeforeDispatchResult, + PluginHookHandlerMap, PluginHookReplyPayloadSendingContext, PluginHookReplyPayloadSendingEvent, PluginHookReplyPayloadSendingResult, @@ -206,6 +207,7 @@ export type HookRunnerOptions = { const DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK: Partial> = { agent_end: 30_000, + channel_pairing_requested: 2_000, // Defensive default for the compaction lifecycle hooks. Without a budget an // unresponsive handler runs fully unbounded, and in the codex agent harness // these hooks fire on the serialized notification queue @@ -1064,6 +1066,17 @@ export function createHookRunner( return runVoidHook("message_received", event, ctx); } + /** + * Run channel_pairing_requested hook. + * Observation-only; slow/failing handlers must not block pairing flow. + */ + async function runChannelPairingRequested( + event: Parameters[0], + ctx: Parameters[1], + ): Promise { + return runVoidHook("channel_pairing_requested", event, ctx); + } + /** * Run before_dispatch hook. * Allows plugins to inspect or handle a message before model dispatch. @@ -1643,6 +1656,7 @@ export function createHookRunner( runInboundClaim, runInboundClaimForPlugin, runInboundClaimForPluginOutcome, + runChannelPairingRequested, runMessageReceived, runBeforeDispatch, runReplyDispatch,