feat: add internal hooks system

This commit is contained in:
Peter Steinberger
2026-01-17 01:31:39 +00:00
parent a76cbc43bb
commit faba508fe0
39 changed files with 4241 additions and 28 deletions

View File

@@ -1,6 +1,8 @@
import { logVerbose } from "../../globals.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { routeReply } from "./route-reply.js";
import { handleBashCommand } from "./commands-bash.js";
import { handleCompactCommand } from "./commands-compact.js";
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
@@ -42,9 +44,8 @@ const HANDLERS: CommandHandler[] = [
];
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
const resetRequested =
params.command.commandBodyNormalized === "/reset" ||
params.command.commandBodyNormalized === "/new";
const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/);
const resetRequested = Boolean(resetMatch);
if (resetRequested && !params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /reset from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
@@ -52,6 +53,45 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
return { shouldContinue: false };
}
// Trigger internal hook for reset/new commands
if (resetRequested && params.command.isAuthorizedSender) {
const commandAction = resetMatch?.[1] ?? "new";
const hookEvent = createInternalHookEvent(
'command',
commandAction,
params.sessionKey ?? '',
{
sessionEntry: params.sessionEntry,
previousSessionEntry: params.previousSessionEntry,
commandSource: params.command.surface,
senderId: params.command.senderId,
cfg: params.cfg, // Pass config for LLM slug generation
}
);
await triggerInternalHook(hookEvent);
// Send hook messages immediately if present
if (hookEvent.messages.length > 0) {
// Use OriginatingChannel/To if available, otherwise fall back to command channel/from
const channel = params.ctx.OriginatingChannel || (params.command.channel as any);
// For replies, use 'from' (the sender) not 'to' (which might be the bot itself)
const to = params.ctx.OriginatingTo || params.command.from || params.command.to;
if (channel && to) {
const hookReply = { text: hookEvent.messages.join('\n\n') };
await routeReply({
payload: hookReply,
channel: channel,
to: to,
sessionKey: params.sessionKey,
accountId: params.ctx.AccountId,
threadId: params.ctx.MessageThreadId,
cfg: params.cfg,
});
}
}
}
const allowTextCommands = shouldHandleTextCommands({
cfg: params.cfg,
surface: params.command.surface,

View File

@@ -2,6 +2,7 @@ import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import type { SessionEntry } from "../../config/sessions.js";
import { updateSessionStore } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../infra/restart.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { parseActivationCommand } from "../group-activation.js";
@@ -12,8 +13,8 @@ import {
setAbortMemory,
stopSubagentsForRequester,
} from "./abort.js";
import { clearSessionQueues } from "./queue.js";
import type { CommandHandler } from "./commands-types.js";
import { clearSessionQueues } from "./queue.js";
function resolveSessionEntryForKey(
store: Record<string, SessionEntry> | undefined,
@@ -213,14 +214,27 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand
} else if (params.command.abortKey) {
setAbortMemory(params.command.abortKey, true);
}
// Trigger internal hook for stop command
const hookEvent = createInternalHookEvent(
'command',
'stop',
abortTarget.key ?? params.sessionKey ?? '',
{
sessionEntry: abortTarget.entry ?? params.sessionEntry,
sessionId: abortTarget.sessionId,
commandSource: params.command.surface,
senderId: params.command.senderId,
}
);
await triggerInternalHook(hookEvent);
const { stopped } = stopSubagentsForRequester({
cfg: params.cfg,
requesterSessionKey: abortTarget.key ?? params.sessionKey,
});
return {
shouldContinue: false,
reply: { text: formatAbortReplyText(stopped) },
};
return { shouldContinue: false, reply: { text: formatAbortReplyText(stopped) } };
};
export const handleAbortTrigger: CommandHandler = async (params, allowTextCommands) => {
@@ -235,12 +249,6 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
if (abortTarget.sessionId) {
abortEmbeddedPiRun(abortTarget.sessionId);
}
const cleared = clearSessionQueues([abortTarget.key, abortTarget.sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`stop-trigger: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
if (abortTarget.entry && params.sessionStore && abortTarget.key) {
abortTarget.entry.abortedLastRun = true;
abortTarget.entry.updatedAt = Date.now();
@@ -253,12 +261,5 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
} else if (params.command.abortKey) {
setAbortMemory(params.command.abortKey, true);
}
const { stopped } = stopSubagentsForRequester({
cfg: params.cfg,
requesterSessionKey: abortTarget.key ?? params.sessionKey,
});
return {
shouldContinue: false,
reply: { text: formatAbortReplyText(stopped) },
};
return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } };
};

View File

@@ -33,6 +33,7 @@ export type HandleCommandsParams = {
failures: Array<{ gate: string; key: string }>;
};
sessionEntry?: SessionEntry;
previousSessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import * as internalHooks from "../../hooks/internal-hooks.js";
import type { MsgContext } from "../templating.js";
import { resetBashChatCommandForTests } from "./bash-command.js";
import { buildCommandContext, handleCommands } from "./commands.js";
@@ -143,6 +144,24 @@ describe("handleCommands identity", () => {
});
});
describe("handleCommands internal hooks", () => {
it("triggers hooks for /new with arguments", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/new take notes", cfg);
const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
await handleCommands(params);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({ type: "command", action: "new" }),
);
spy.mockRestore();
});
});
describe("handleCommands context", () => {
it("returns context help for /context", async () => {
const cfg = {

View File

@@ -29,6 +29,7 @@ export async function handleInlineActions(params: {
cfg: ClawdbotConfig;
agentId: string;
sessionEntry?: SessionEntry;
previousSessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
@@ -66,8 +67,9 @@ export async function handleInlineActions(params: {
sessionCtx,
cfg,
agentId,
sessionEntry,
sessionStore,
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
storePath,
sessionScope,
@@ -203,6 +205,7 @@ export async function handleInlineActions(params: {
failures: elevatedFailures,
},
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
storePath,
@@ -265,6 +268,7 @@ export async function handleInlineActions(params: {
failures: elevatedFailures,
},
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
storePath,

View File

@@ -100,6 +100,7 @@ export async function getReplyFromConfig(
let {
sessionCtx,
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
sessionId,
@@ -122,6 +123,7 @@ export async function getReplyFromConfig(
agentCfg,
sessionCtx,
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
storePath,

View File

@@ -30,6 +30,7 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
export type SessionInitResult = {
sessionCtx: TemplateContext;
sessionEntry: SessionEntry;
previousSessionEntry?: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
sessionId: string;
@@ -115,6 +116,7 @@ export async function initSessionState(params: {
let bodyStripped: string | undefined;
let systemSent = false;
let abortedLastRun = false;
let resetTriggered = false;
let persistedThinking: string | undefined;
let persistedVerbose: string | undefined;
@@ -149,12 +151,14 @@ export async function initSessionState(params: {
if (trimmedBody === trigger || strippedForReset === trigger) {
isNewSession = true;
bodyStripped = "";
resetTriggered = true;
break;
}
const triggerPrefix = `${trigger} `;
if (trimmedBody.startsWith(triggerPrefix) || strippedForReset.startsWith(triggerPrefix)) {
isNewSession = true;
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
resetTriggered = true;
break;
}
}
@@ -168,6 +172,7 @@ export async function initSessionState(params: {
}
}
const entry = sessionStore[sessionKey];
const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
const idleMs = idleMinutes * 60_000;
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
@@ -308,6 +313,7 @@ export async function initSessionState(params: {
return {
sessionCtx,
sessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
sessionId: sessionId ?? crypto.randomUUID(),

View File

@@ -75,6 +75,11 @@ export type MsgContext = {
* The chat/channel/user ID where the reply should be sent.
*/
OriginatingTo?: string;
/**
* Messages from internal hooks to be included in the response.
* Used for hook confirmation messages like "Session context saved to memory".
*/
HookMessages?: string[];
};
export type TemplateContext = MsgContext & {