feat(exec-approvals): batch approval for rapid-fire commands

When an agent issues multiple commands requiring approval in rapid
succession, users previously had to approve each one individually.
This adds smart batching that collects requests and presents them as a
single message listing all pending commands:

- Initial 1.5s window collects rapid-fire commands
- Extended 10s window when session already has pending approvals
- Maximum 30s cap prevents indefinite batching
- `/approve-batch <batch-id> allow-once|deny` command works on all channels

On Telegram, the batched message includes "Approve All (N)" / "Deny All"
inline buttons. Other channels receive the same grouped text and can use
the `/approve-batch` command manually.

Also fixes a command-routing bug where `/approve-batch` was intercepted
by the `/approve` handler due to prefix matching, causing batch buttons
to silently fail.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Ring 2026-01-26 21:26:07 -08:00
parent 4bef31e009
commit 340e947d49
6 changed files with 527 additions and 20 deletions

View File

@ -253,6 +253,27 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendPayload: async (ctx) => {
const send =
ctx.deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
const replyToMessageId = parseReplyToMessageId(ctx.replyToId);
const messageThreadId = parseThreadId(ctx.threadId);
// Extract buttons from channelData.telegram.buttons
type TelegramButtons = Array<Array<{ text: string; callback_data: string }>>;
const channelData = ctx.payload.channelData;
const telegram = channelData?.telegram as { buttons?: TelegramButtons } | undefined;
const buttons = telegram?.buttons;
const result = await send(ctx.to, ctx.text, {
verbose: false,
mediaUrl: ctx.mediaUrl,
textMode: "html",
messageThreadId,
replyToMessageId,
accountId: ctx.accountId ?? undefined,
buttons,
});
return { channel: "telegram", ...result };
},
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send =
deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;

View File

@ -1,15 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
import { callGateway } from "../../gateway/call.js";
import * as approvalForwarder from "../../infra/exec-approval-forwarder.js";
vi.mock("../../gateway/call.js", () => ({
callGateway: vi.fn(),
}));
vi.mock("../../infra/exec-approval-forwarder.js", () => ({
getBatchApprovalIds: vi.fn(),
deleteBatch: vi.fn(),
}));
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
@ -49,6 +55,10 @@ function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Pa
}
describe("/approve command", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejects invalid usage", async () => {
const cfg = {
commands: { text: true },
@ -80,4 +90,105 @@ describe("/approve command", () => {
}),
);
});
it("does not intercept /approve-batch", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/approve-batch test-batch allow-once", cfg, { SenderId: "123" });
vi.mocked(approvalForwarder.getBatchApprovalIds).mockReturnValue(null);
const result = await handleCommands(params);
// Should be handled by /approve-batch, not /approve
expect(result.reply?.text).toContain("Batch not found");
expect(result.reply?.text).not.toContain("Usage: /approve");
});
});
describe("/approve-batch command", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejects invalid usage", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/approve-batch", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Usage: /approve-batch");
});
it("rejects unknown batch ID", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/approve-batch unknown-batch allow-once", cfg, { SenderId: "123" });
vi.mocked(approvalForwarder.getBatchApprovalIds).mockReturnValue(null);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Batch not found or expired");
});
it("approves all commands in batch", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/approve-batch batch-123 allow-once", cfg, { SenderId: "123" });
vi.mocked(approvalForwarder.getBatchApprovalIds).mockReturnValue(["id1", "id2", "id3"]);
const mockCallGateway = vi.mocked(callGateway);
mockCallGateway.mockResolvedValue({ ok: true });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("3 commands approved");
expect(mockCallGateway).toHaveBeenCalledTimes(3);
expect(approvalForwarder.deleteBatch).toHaveBeenCalledWith("batch-123");
});
it("denies all commands in batch", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/approve-batch batch-456 deny", cfg, { SenderId: "123" });
vi.mocked(approvalForwarder.getBatchApprovalIds).mockReturnValue(["id1", "id2"]);
const mockCallGateway = vi.mocked(callGateway);
mockCallGateway.mockResolvedValue({ ok: true });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("2 commands approved");
expect(mockCallGateway).toHaveBeenCalledTimes(2);
});
it("reports partial failures", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/approve-batch batch-789 allow-once", cfg, { SenderId: "123" });
vi.mocked(approvalForwarder.getBatchApprovalIds).mockReturnValue(["id1", "id2", "id3"]);
const mockCallGateway = vi.mocked(callGateway);
mockCallGateway
.mockResolvedValueOnce({ ok: true })
.mockRejectedValueOnce(new Error("Failed"))
.mockResolvedValueOnce({ ok: true });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("2 succeeded");
expect(result.reply?.text).toContain("1 failed");
});
});

View File

@ -1,9 +1,11 @@
import { callGateway } from "../../gateway/call.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { logVerbose } from "../../globals.js";
import { getBatchApprovalIds, deleteBatch } from "../../infra/exec-approval-forwarder.js";
import type { CommandHandler } from "./commands-types.js";
const COMMAND = "/approve";
const BATCH_COMMAND = "/approve-batch";
const DECISION_ALIASES: Record<string, "allow-once" | "allow-always" | "deny"> = {
allow: "allow-once",
@ -24,7 +26,10 @@ type ParsedApproveCommand =
function parseApproveCommand(raw: string): ParsedApproveCommand | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith(COMMAND)) return null;
const lower = trimmed.toLowerCase();
// Don't match /approve-batch - it has its own handler
if (!lower.startsWith(COMMAND)) return null;
if (lower.startsWith(BATCH_COMMAND)) return null;
const rest = trimmed.slice(COMMAND.length).trim();
if (!rest) {
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
@ -54,6 +59,44 @@ function parseApproveCommand(raw: string): ParsedApproveCommand | null {
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
}
type ParsedBatchCommand =
| { ok: true; batchId: string; decision: "allow-once" | "deny" }
| { ok: false; error: string };
function parseBatchCommand(raw: string): ParsedBatchCommand | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith(BATCH_COMMAND)) return null;
const rest = trimmed.slice(BATCH_COMMAND.length).trim();
if (!rest) {
return { ok: false, error: "Usage: /approve-batch <batch-id> allow-once|deny" };
}
const tokens = rest.split(/\s+/).filter(Boolean);
if (tokens.length < 2) {
return { ok: false, error: "Usage: /approve-batch <batch-id> allow-once|deny" };
}
const batchId = tokens[0];
const decisionRaw = tokens[1].toLowerCase();
// Batch only supports allow-once and deny
const batchDecisions: Record<string, "allow-once" | "deny"> = {
allow: "allow-once",
once: "allow-once",
"allow-once": "allow-once",
allowonce: "allow-once",
deny: "deny",
reject: "deny",
block: "deny",
};
const decision = batchDecisions[decisionRaw];
if (!decision) {
return { ok: false, error: "Usage: /approve-batch <batch-id> allow-once|deny" };
}
return { ok: true, batchId, decision };
}
function buildResolvedByLabel(params: Parameters<CommandHandler>[0]): string {
const channel = params.command.channel;
const sender = params.command.senderId ?? "unknown";
@ -99,3 +142,70 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
reply: { text: `✅ Exec approval ${parsed.decision} submitted for ${parsed.id}.` },
};
};
export const handleApproveBatchCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const normalized = params.command.commandBodyNormalized;
const parsed = parseBatchCommand(normalized);
if (!parsed) return null;
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /approve-batch from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
if (!parsed.ok) {
return { shouldContinue: false, reply: { text: parsed.error } };
}
// Look up the batch
const approvalIds = getBatchApprovalIds(parsed.batchId);
if (!approvalIds || approvalIds.length === 0) {
return {
shouldContinue: false,
reply: { text: `❌ Batch not found or expired: ${parsed.batchId}` },
};
}
const resolvedBy = buildResolvedByLabel(params);
const results: { id: string; success: boolean; error?: string }[] = [];
// Resolve all approvals in the batch
for (const id of approvalIds) {
try {
await callGateway({
method: "exec.approval.resolve",
params: { id, decision: parsed.decision },
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: `Batch approval (${resolvedBy})`,
mode: GATEWAY_CLIENT_MODES.BACKEND,
});
results.push({ id, success: true });
} catch (err) {
results.push({ id, success: false, error: String(err) });
}
}
// Clean up the batch
deleteBatch(parsed.batchId);
const succeeded = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
if (failed === 0) {
return {
shouldContinue: false,
reply: {
text: `✅ Batch ${parsed.decision}: ${succeeded} command${succeeded > 1 ? "s" : ""} approved.`,
},
};
}
return {
shouldContinue: false,
reply: {
text: `⚠️ Batch ${parsed.decision}: ${succeeded} succeeded, ${failed} failed.`,
},
};
};

View File

@ -14,7 +14,7 @@ import {
handleWhoamiCommand,
} from "./commands-info.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleApproveCommand } from "./commands-approve.js";
import { handleApproveCommand, handleApproveBatchCommand } from "./commands-approve.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleModelsCommand } from "./commands-models.js";
import { handleTtsCommands } from "./commands-tts.js";
@ -47,6 +47,7 @@ const HANDLERS: CommandHandler[] = [
handleStatusCommand,
handleAllowlistCommand,
handleApproveCommand,
handleApproveBatchCommand,
handleContextCommand,
handleWhoamiCommand,
handleSubagentsCommand,

View File

@ -14,6 +14,12 @@ import { resolveSessionDeliveryTarget } from "./outbound/targets.js";
const log = createSubsystemLogger("gateway/exec-approvals");
// Batch approval constants
const BATCH_WINDOW_MS = 1500; // Initial window: collect requests for 1.5 seconds
const BATCH_EXTEND_WINDOW_MS = 10000; // Extended window when session has pending approvals
const BATCH_MAX_WINDOW_MS = 30000; // Maximum time to wait before flushing (30 seconds)
const BATCH_TTL_MS = 5 * 60 * 1000; // Batch IDs expire after 5 minutes
export type ExecApprovalRequest = {
id: string;
request: {
@ -43,8 +49,51 @@ type PendingApproval = {
request: ExecApprovalRequest;
targets: ForwardTarget[];
timeoutId: NodeJS.Timeout | null;
batchId?: string;
};
type PendingBatch = {
sessionKey: string;
requests: ExecApprovalRequest[];
targets: ForwardTarget[];
flushTimeoutId: NodeJS.Timeout | null;
createdAtMs: number;
lastRequestAtMs: number;
};
type BatchEntry = {
approvalIds: string[];
sessionKey: string;
createdAtMs: number;
};
// Global batch registry - maps batch IDs to their approval IDs
const batchRegistry = new Map<string, BatchEntry>();
function generateBatchId(): string {
return `batch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function cleanupExpiredBatches(nowMs: number): void {
for (const [batchId, entry] of batchRegistry) {
if (nowMs - entry.createdAtMs > BATCH_TTL_MS) {
batchRegistry.delete(batchId);
}
}
}
export function getBatchApprovalIds(batchId: string): string[] | null {
const entry = batchRegistry.get(batchId);
if (!entry) return null;
// Cleanup expired batches opportunistically
cleanupExpiredBatches(Date.now());
return entry.approvalIds;
}
export function deleteBatch(batchId: string): void {
batchRegistry.delete(batchId);
}
export type ExecApprovalForwarder = {
handleRequested: (request: ExecApprovalRequest) => Promise<void>;
handleResolved: (resolved: ExecApprovalResolved) => Promise<void>;
@ -105,7 +154,12 @@ function buildTargetKey(target: ExecApprovalForwardTarget): string {
return [channel, target.to, accountId, threadId].join(":");
}
function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) {
type ApprovalMessage = {
text: string;
buttons: Array<Array<{ text: string; callback_data: string }>>;
};
function buildRequestMessage(request: ExecApprovalRequest, nowMs: number): ApprovalMessage {
const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`];
lines.push(`Command: ${request.request.command}`);
if (request.request.cwd) lines.push(`CWD: ${request.request.cwd}`);
@ -115,8 +169,16 @@ function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) {
if (request.request.ask) lines.push(`Ask: ${request.request.ask}`);
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000));
lines.push(`Expires in: ${expiresIn}s`);
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
return lines.join("\n");
const buttons = [
[
{ text: "✅ Allow Once", callback_data: `/approve ${request.id} allow-once` },
{ text: "✅ Always Allow", callback_data: `/approve ${request.id} allow-always` },
],
[{ text: "❌ Deny", callback_data: `/approve ${request.id} deny` }],
];
return { text: lines.join("\n"), buttons };
}
function decisionLabel(decision: ExecApprovalDecision): string {
@ -135,6 +197,48 @@ function buildExpiredMessage(request: ExecApprovalRequest) {
return `⏱️ Exec approval expired. ID: ${request.id}`;
}
function buildBatchRequestMessage(
requests: ExecApprovalRequest[],
batchId: string,
nowMs: number,
): ApprovalMessage {
const count = requests.length;
const lines: string[] = [`🔒 Exec approval required (${count} command${count > 1 ? "s" : ""})`];
lines.push("");
// List each command with a number
requests.forEach((req, idx) => {
const cmd = req.request.command;
// Truncate long commands for readability
const displayCmd = cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
lines.push(`${idx + 1}. ${displayCmd}`);
});
lines.push("");
// Show session info if available
const sessionKey = requests[0]?.request.sessionKey;
if (sessionKey) {
// Truncate long session keys
const displayKey = sessionKey.length > 40 ? "..." + sessionKey.slice(-37) : sessionKey;
lines.push(`Session: ${displayKey}`);
}
// Use the earliest expiry time
const earliestExpiry = Math.min(...requests.map((r) => r.expiresAtMs));
const expiresIn = Math.max(0, Math.round((earliestExpiry - nowMs) / 1000));
lines.push(`Expires in: ${expiresIn}s`);
const buttons = [
[
{ text: `✅ Approve All (${count})`, callback_data: `/approve-batch ${batchId} allow-once` },
{ text: `❌ Deny All`, callback_data: `/approve-batch ${batchId} deny` },
],
];
return { text: lines.join("\n"), buttons };
}
function defaultResolveSessionTarget(params: {
cfg: ClawdbotConfig;
request: ExecApprovalRequest;
@ -158,10 +262,25 @@ function defaultResolveSessionTarget(params: {
};
}
type TelegramButtons = Array<Array<{ text: string; callback_data: string }>>;
function buildPayloadWithButtons(text: string, buttons?: TelegramButtons) {
if (!buttons?.length) {
log.debug("exec approvals: buildPayloadWithButtons called without buttons");
return { text };
}
log.debug(`exec approvals: buildPayloadWithButtons adding ${buttons.length} button rows`);
return {
text,
channelData: { telegram: { buttons } },
};
}
async function deliverToTargets(params: {
cfg: ClawdbotConfig;
targets: ForwardTarget[];
text: string;
buttons?: TelegramButtons;
deliver: typeof deliverOutboundPayloads;
shouldSend?: () => boolean;
}) {
@ -170,13 +289,17 @@ async function deliverToTargets(params: {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
if (!isDeliverableMessageChannel(channel)) return;
try {
const payload = buildPayloadWithButtons(params.text, params.buttons);
log.info(
`exec approvals: delivering to ${channel}:${target.to} payload=${JSON.stringify(payload).slice(0, 200)}`,
);
await params.deliver({
cfg: params.cfg,
channel,
to: target.to,
accountId: target.accountId,
threadId: target.threadId,
payloads: [{ text: params.text }],
payloads: [payload],
});
} catch (err) {
log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`);
@ -193,12 +316,10 @@ export function createExecApprovalForwarder(
const nowMs = deps.nowMs ?? Date.now;
const resolveSessionTarget = deps.resolveSessionTarget ?? defaultResolveSessionTarget;
const pending = new Map<string, PendingApproval>();
const pendingBatches = new Map<string, PendingBatch>();
const handleRequested = async (request: ExecApprovalRequest) => {
const cfg = getConfig();
const resolveTargets = (cfg: ClawdbotConfig, request: ExecApprovalRequest): ForwardTarget[] => {
const config = cfg.approvals?.exec;
if (!shouldForward({ config, request })) return;
const mode = normalizeMode(config?.mode);
const targets: ForwardTarget[] = [];
const seen = new Set<string>();
@ -224,8 +345,83 @@ export function createExecApprovalForwarder(
}
}
return targets;
};
const flushBatch = async (sessionKey: string) => {
const batch = pendingBatches.get(sessionKey);
if (!batch) return;
pendingBatches.delete(sessionKey);
if (batch.flushTimeoutId) clearTimeout(batch.flushTimeoutId);
const cfg = getConfig();
const { requests, targets } = batch;
if (requests.length === 0 || targets.length === 0) return;
// Generate batch ID and register it
const batchId = generateBatchId();
const approvalIds = requests.map((r) => r.id);
batchRegistry.set(batchId, {
approvalIds,
sessionKey,
createdAtMs: nowMs(),
});
// Update pending entries with batch ID
for (const req of requests) {
const entry = pending.get(req.id);
if (entry) entry.batchId = batchId;
}
log.info(`exec approvals: flushing batch ${batchId} with ${requests.length} requests`);
// Send single or batch message depending on count
if (requests.length === 1) {
// Single request - use original format with all buttons
const message = buildRequestMessage(requests[0], nowMs());
await deliverToTargets({
cfg,
targets,
text: message.text,
buttons: message.buttons,
deliver,
shouldSend: () => pending.has(requests[0].id),
});
} else {
// Multiple requests - use batch format
const message = buildBatchRequestMessage(requests, batchId, nowMs());
await deliverToTargets({
cfg,
targets,
text: message.text,
buttons: message.buttons,
deliver,
});
}
};
// Count pending (unresolved) approvals for a session
const countPendingForSession = (sessionKey: string): number => {
let count = 0;
for (const entry of pending.values()) {
if (entry.request.request.sessionKey === sessionKey) {
count++;
}
}
return count;
};
const handleRequested = async (request: ExecApprovalRequest) => {
const cfg = getConfig();
const config = cfg.approvals?.exec;
if (!shouldForward({ config, request })) return;
const targets = resolveTargets(cfg, request);
if (targets.length === 0) return;
// Set up expiry timeout for this request
const expiresInMs = Math.max(0, request.expiresAtMs - nowMs());
const timeoutId = setTimeout(() => {
void (async () => {
@ -241,16 +437,75 @@ export function createExecApprovalForwarder(
const pendingEntry: PendingApproval = { request, targets, timeoutId };
pending.set(request.id, pendingEntry);
if (pending.get(request.id) !== pendingEntry) return;
// Batch by session key
const sessionKey = request.request.sessionKey ?? "default";
const now = nowMs();
let batch = pendingBatches.get(sessionKey);
const text = buildRequestMessage(request, nowMs());
await deliverToTargets({
cfg,
targets,
text,
deliver,
shouldSend: () => pending.get(request.id) === pendingEntry,
});
// Check if there are already pending (unresolved) approvals for this session
// (excluding the one we just added)
const existingPendingCount = countPendingForSession(sessionKey) - 1;
const hasPendingApprovals = existingPendingCount > 0;
if (!batch) {
// Start a new batch
// Use extended window if there are already pending approvals for this session
const windowMs = hasPendingApprovals ? BATCH_EXTEND_WINDOW_MS : BATCH_WINDOW_MS;
batch = {
sessionKey,
requests: [],
targets,
flushTimeoutId: null,
createdAtMs: now,
lastRequestAtMs: now,
};
pendingBatches.set(sessionKey, batch);
log.info(
`exec approvals: starting new batch for session, window=${windowMs}ms, hasPending=${hasPendingApprovals}`,
);
// Set up flush timeout
batch.flushTimeoutId = setTimeout(() => {
void flushBatch(sessionKey);
}, windowMs);
batch.flushTimeoutId.unref?.();
} else {
// Batch already exists - extend the window if we haven't hit the max
const batchAge = now - batch.createdAtMs;
const timeRemaining = BATCH_MAX_WINDOW_MS - batchAge;
if (timeRemaining > 0) {
// Clear existing timeout and set a new one
if (batch.flushTimeoutId) clearTimeout(batch.flushTimeoutId);
// Use the shorter of: extended window or time remaining until max
const extensionMs = Math.min(BATCH_EXTEND_WINDOW_MS, timeRemaining);
log.info(
`exec approvals: extending batch window by ${extensionMs}ms (age=${batchAge}ms, requests=${batch.requests.length + 1})`,
);
batch.flushTimeoutId = setTimeout(() => {
void flushBatch(sessionKey);
}, extensionMs);
batch.flushTimeoutId.unref?.();
} else {
log.info(`exec approvals: batch at max window (${BATCH_MAX_WINDOW_MS}ms), will flush soon`);
}
batch.lastRequestAtMs = now;
}
batch.requests.push(request);
// Merge targets (in case they differ, though unlikely)
for (const target of targets) {
const key = buildTargetKey(target);
if (!batch.targets.some((t) => buildTargetKey(t) === key)) {
batch.targets.push(target);
}
}
};
const handleResolved = async (resolved: ExecApprovalResolved) => {
@ -269,6 +524,10 @@ export function createExecApprovalForwarder(
if (entry.timeoutId) clearTimeout(entry.timeoutId);
}
pending.clear();
for (const batch of pendingBatches.values()) {
if (batch.flushTimeoutId) clearTimeout(batch.flushTimeoutId);
}
pendingBatches.clear();
};
return { handleRequested, handleResolved, stop };

View File

@ -321,7 +321,12 @@ export async function deliverOutboundPayloads(params: {
try {
throwIfAborted(abortSignal);
params.onPayload?.(payloadSummary);
// Debug: check sendPayload path
console.log(
`[outbound] channel=${channel} hasSendPayload=${!!handler.sendPayload} hasChannelData=${!!payload.channelData} channelData=${JSON.stringify(payload.channelData)}`,
);
if (handler.sendPayload && payload.channelData) {
console.log(`[outbound] using sendPayload for ${channel}`);
results.push(await handler.sendPayload(payload));
continue;
}