diff --git a/CHANGELOG.md b/CHANGELOG.md index dc24979f7..ec7a52e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ - Providers: skip DM history limit handling for non-DM sessions. (#728) — thanks @pkrmf. - Sandbox: fix non-main mode incorrectly sandboxing the main DM session and align `/status` runtime reporting with effective sandbox state. - Sandbox/Gateway: treat `agent::main` as a main-session alias when `session.mainKey` is customized (backwards compatible). +- Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model). ## 2026.1.10 diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 67de20874..6f552d49f 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -68,6 +68,9 @@ Notes: - `/restart` is disabled by default; set `commands.restart: true` to enable it. - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. +- **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model). +- **Inline shortcuts:** `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`) are also parsed when embedded in text. They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow. +- Unauthorized command-only messages are silently ignored. ## Debug overrides diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 7413e93d0..8f047ad6d 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -520,7 +520,7 @@ describe("trigger handling", () => { }); }); - it("ignores inline /status and runs the agent", async () => { + it("handles inline /status and still runs the agent", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "ok" }], @@ -529,21 +529,132 @@ describe("trigger handling", () => { agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, }); + const blockReplies: Array<{ text?: string }> = []; const res = await getReplyFromConfig( { Body: "please /status now", From: "+1002", To: "+2000", }, - {}, + { + onBlockReply: async (payload) => { + blockReplies.push(payload); + }, + }, makeCfg(home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).not.toContain("Status"); + expect(blockReplies.length).toBe(1); + expect(blockReplies[0]?.text).toBeTruthy(); expect(runEmbeddedPiAgent).toHaveBeenCalled(); }); }); + it("handles inline /help and strips it before the agent", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const blockReplies: Array<{ text?: string }> = []; + const res = await getReplyFromConfig( + { + Body: "please /help now", + From: "+1002", + To: "+2000", + }, + { + onBlockReply: async (payload) => { + blockReplies.push(payload); + }, + }, + makeCfg(home), + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(blockReplies.length).toBe(1); + expect(blockReplies[0]?.text).toContain("Help"); + expect(runEmbeddedPiAgent).toHaveBeenCalled(); + const prompt = + vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; + expect(prompt).not.toContain("/help"); + expect(text).toBe("ok"); + }); + }); + + it("drops /status for unauthorized senders", async () => { + await withTempHome(async (home) => { + const cfg = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + }, + whatsapp: { + allowFrom: ["+1000"], + }, + session: { store: join(home, "sessions.json") }, + }; + const res = await getReplyFromConfig( + { + Body: "/status", + From: "+2001", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+2001", + }, + {}, + cfg, + ); + expect(res).toBeUndefined(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("keeps inline /help for unauthorized senders", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const cfg = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + }, + whatsapp: { + allowFrom: ["+1000"], + }, + session: { store: join(home, "sessions.json") }, + }; + const res = await getReplyFromConfig( + { + Body: "please /help now", + From: "+2001", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+2001", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("ok"); + expect(runEmbeddedPiAgent).toHaveBeenCalled(); + const prompt = + vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; + expect(prompt).toContain("/help"); + }); + }); + it("returns help without invoking the agent", async () => { await withTempHome(async (home) => { const res = await getReplyFromConfig( diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 3710eca98..7f637cbc0 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -148,6 +148,29 @@ function stripSenderPrefix(value?: string) { return trimmed.replace(SENDER_PREFIX_RE, ""); } +const INLINE_SIMPLE_COMMAND_ALIASES = new Map([ + ["/help", "/help"], + ["/commands", "/commands"], + ["/whoami", "/whoami"], + ["/id", "/whoami"], +]); +const INLINE_SIMPLE_COMMAND_RE = + /(?:^|\s)\/(help|commands|whoami|id)(?=$|\s|:)/i; + +function extractInlineSimpleCommand(body?: string): { + command: string; + cleaned: string; +} | null { + if (!body) return null; + const match = body.match(INLINE_SIMPLE_COMMAND_RE); + if (!match || match.index === undefined) return null; + const alias = `/${match[1].toLowerCase()}`; + const command = INLINE_SIMPLE_COMMAND_ALIASES.get(alias); + if (!command) return null; + const cleaned = body.replace(match[0], " ").replace(/\s+/g, " ").trim(); + return { command, cleaned }; +} + function resolveElevatedAllowList( allowFrom: AgentElevatedAllowFromConfig | undefined, provider: string, @@ -489,7 +512,7 @@ export async function getReplyFromConfig( } } } - const directives = commandAuthorized + let directives = commandAuthorized ? parsedDirectives : { ...parsedDirectives, @@ -502,7 +525,7 @@ export async function getReplyFromConfig( queueReset: false, }; const existingBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; - const cleanedBody = (() => { + let cleanedBody = (() => { if (!existingBody) return parsedDirectives.cleaned; if (!sessionCtx.CommandBody && !sessionCtx.RawBody) { return parseInlineDirectives(existingBody, { @@ -660,6 +683,19 @@ export async function getReplyFromConfig( surface: command.surface, commandSource: ctx.CommandSource, }); + if (!command.isAuthorizedSender) { + directives = { + ...directives, + hasThinkDirective: false, + hasVerboseDirective: false, + hasReasoningDirective: false, + hasElevatedDirective: false, + hasStatusDirective: false, + hasModelDirective: false, + hasQueueDirective: false, + queueReset: false, + }; + } if ( isDirectiveOnly({ @@ -671,6 +707,10 @@ export async function getReplyFromConfig( isGroup, }) ) { + if (!command.isAuthorizedSender) { + typing.cleanup(); + return undefined; + } const resolvedDefaultThinkLevel = (sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? @@ -711,7 +751,11 @@ export async function getReplyFromConfig( currentElevatedLevel, }); let statusReply: ReplyPayload | undefined; - if (directives.hasStatusDirective && allowTextCommands) { + if ( + directives.hasStatusDirective && + allowTextCommands && + command.isAuthorizedSender + ) { statusReply = await buildStatusReply({ cfg, command, @@ -776,6 +820,94 @@ export async function getReplyFromConfig( } : undefined; + const sendInlineReply = async (reply?: ReplyPayload) => { + if (!reply) return; + if (!opts?.onBlockReply) return; + await opts.onBlockReply(reply); + }; + + const inlineCommand = + allowTextCommands && command.isAuthorizedSender + ? extractInlineSimpleCommand(cleanedBody) + : null; + if (inlineCommand) { + cleanedBody = inlineCommand.cleaned; + sessionCtx.Body = cleanedBody; + sessionCtx.BodyStripped = cleanedBody; + } + + const handleInlineStatus = + !isDirectiveOnly({ + directives, + cleanedBody: directives.cleaned, + ctx, + cfg, + agentId, + isGroup, + }) && + directives.hasStatusDirective && + allowTextCommands && + command.isAuthorizedSender; + if (handleInlineStatus) { + const inlineStatusReply = await buildStatusReply({ + cfg, + command, + sessionEntry, + sessionKey, + sessionScope, + provider, + model, + contextTokens, + resolvedThinkLevel, + resolvedVerboseLevel: resolvedVerboseLevel ?? "off", + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, + isGroup, + defaultGroupActivation: () => defaultActivation, + }); + await sendInlineReply(inlineStatusReply); + directives = { ...directives, hasStatusDirective: false }; + } + + if (inlineCommand) { + const inlineCommandContext = { + ...command, + rawBodyNormalized: inlineCommand.command, + commandBodyNormalized: inlineCommand.command, + }; + const inlineResult = await handleCommands({ + ctx, + cfg, + command: inlineCommandContext, + agentId, + directives, + sessionEntry, + sessionStore, + sessionKey, + storePath, + sessionScope, + workspaceDir, + defaultGroupActivation: () => defaultActivation, + resolvedThinkLevel, + resolvedVerboseLevel: resolvedVerboseLevel ?? "off", + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, + provider, + model, + contextTokens, + isGroup, + }); + if (inlineResult.reply) { + if (!inlineCommand.cleaned) { + typing.cleanup(); + return inlineResult.reply; + } + await sendInlineReply(inlineResult.reply); + } + } + const isEmptyConfig = Object.keys(cfg).length === 0; const skipWhenConfigEmpty = command.providerId ? Boolean( @@ -876,7 +1008,7 @@ export async function getReplyFromConfig( const baseBodyTrimmedRaw = baseBody.trim(); if ( allowTextCommands && - !commandAuthorized && + (!commandAuthorized || !command.isAuthorizedSender) && !baseBodyTrimmedRaw && hasControlCommand(commandSource, cfg) ) { diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index b55cf1fd1..fcb7b7e54 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -610,6 +610,12 @@ export async function handleCommands(params: { directives.hasStatusDirective || command.commandBodyNormalized === "/status"; if (allowTextCommands && statusRequested) { + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /status from unauthorized sender: ${command.senderId || ""}`, + ); + return { shouldContinue: false }; + } const reply = await buildStatusReply({ cfg, command, @@ -632,6 +638,12 @@ export async function handleCommands(params: { const whoamiRequested = command.commandBodyNormalized === "/whoami"; if (allowTextCommands && whoamiRequested) { + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /whoami from unauthorized sender: ${command.senderId || ""}`, + ); + return { shouldContinue: false }; + } const senderId = ctx.SenderId ?? ""; const senderUsername = ctx.SenderUsername ?? ""; const lines = ["🧭 Identity", `Provider: ${command.provider}`];