import { getAgentOutputText } from './agent-output.js'; import type { NewMessage } from './types.js'; import { logger } from './logger.js'; import { formatOutbound } from './router.js'; import type { StructuredAgentOutput } from './types.js'; const SESSION_COMMAND_CONTROL_PATTERNS = [ /^Current session cleared\. The next message will start a new conversation\.$/, /^Session commands require admin access\.$/, /^Failed to process messages before \/compact\. Try again\.$/, /^\/compact failed\. The session is unchanged\.$/, /^Conversation compacted\.$/, ]; function normalizeSessionCommandText( content: string, _triggerPattern: RegExp, ): string { return content.trim(); } /** * Extract a session slash command from a message. * Returns the slash command (e.g., '/compact') or null if not a session command. */ export function extractSessionCommand( content: string, triggerPattern: RegExp, ): string | null { const text = normalizeSessionCommandText(content, triggerPattern); if (text === '/compact') return '/compact'; if (text === '/clear') return '/clear'; if (text === '/stop') return '/stop'; return null; } /** * Check if a session command sender is authorized. * Allowed: main group (any sender), or trusted/admin sender in any group. */ export function isSessionCommandAllowed( isMainGroup: boolean, isFromMe: boolean, isAdminSender: boolean = false, ): boolean { return isMainGroup || isFromMe || isAdminSender; } export function isSessionCommandControlMessage(content: string): boolean { const trimmed = content.trim(); return SESSION_COMMAND_CONTROL_PATTERNS.some((pattern) => pattern.test(trimmed), ); } /** Minimal agent result interface — matches the subset of AgentOutput used here. */ export interface AgentResult { status: 'success' | 'error'; result?: string | object | null; output?: StructuredAgentOutput; } /** Dependencies injected by the orchestrator. */ export interface SessionCommandDeps { sendMessage: (text: string) => Promise; setTyping: (typing: boolean) => Promise; runAgent: ( prompt: string, onOutput: (result: AgentResult) => Promise, ) => Promise<'success' | 'error'>; closeStdin: () => void; clearSession: (opts?: { allRoles?: boolean }) => void; advanceCursor: (timestamp: string) => void; formatMessages: (msgs: NewMessage[], timezone: string) => string; isAdminSender: (msg: NewMessage) => boolean; /** Whether the denied sender would normally be allowed to interact (for denial messages). */ canSenderInteract: (msg: NewMessage) => boolean; /** Reset/complete the active paired task so ping-pong stops. */ resetPairedTask?: () => void; /** Kill the currently running agent process for this group. Returns true if a process was killed. */ killProcess: () => boolean; } function agentResultToText(result: AgentResult): string { const raw = getAgentOutputText({ result: result.result ?? null, output: result.output, }); return raw ? formatOutbound(raw) : ''; } /** * Handle session command interception in processGroupMessages. * Scans messages for a session command, handles auth + execution. * Returns { handled: true, success } if a command was found; { handled: false } otherwise. * success=false means the caller should retry (cursor was not advanced). */ export async function handleSessionCommand(opts: { missedMessages: NewMessage[]; isMainGroup: boolean; groupName: string; runId?: string; triggerPattern: RegExp; timezone: string; deps: SessionCommandDeps; }): Promise<{ handled: false } | { handled: true; success: boolean }> { const { missedMessages, isMainGroup, groupName, runId, triggerPattern, timezone, deps, } = opts; const cmdMsg = missedMessages.find( (m) => extractSessionCommand(m.content, triggerPattern) !== null, ); const command = cmdMsg ? extractSessionCommand(cmdMsg.content, triggerPattern) : null; if (!command || !cmdMsg) return { handled: false }; if ( !isSessionCommandAllowed( isMainGroup, cmdMsg.is_from_me === true, deps.isAdminSender(cmdMsg), ) ) { // DENIED: send denial if the sender would normally be allowed to interact, // then silently consume the command by advancing the cursor past it. // Trade-off: other messages in the same batch are also consumed (cursor is // a high-water mark). Acceptable for this narrow edge case. if (deps.canSenderInteract(cmdMsg)) { await deps.sendMessage('Session commands require admin access.'); } deps.advanceCursor(cmdMsg.timestamp); return { handled: true, success: true }; } // AUTHORIZED: process pre-compact messages first, then run the command logger.info({ group: groupName, runId, command }, 'Session command'); if (command === '/clear') { deps.closeStdin(); deps.clearSession({ allRoles: true }); deps.resetPairedTask?.(); deps.advanceCursor(cmdMsg.timestamp); await deps.sendMessage( 'Current session cleared. The next message will start a new conversation.', ); return { handled: true, success: true }; } if (command === '/stop') { const killed = deps.killProcess(); deps.resetPairedTask?.(); deps.advanceCursor(cmdMsg.timestamp); await deps.sendMessage( killed ? 'Agent stopped.' : 'No agent is currently running in this room.', ); return { handled: true, success: true }; } const cmdIndex = missedMessages.indexOf(cmdMsg); const preCompactMsgs = missedMessages.slice(0, cmdIndex); // Send pre-compact messages to the agent so they're in the session context. if (preCompactMsgs.length > 0) { const prePrompt = deps.formatMessages(preCompactMsgs, timezone); let hadPreError = false; let preOutputSent = false; const preResult = await deps.runAgent(prePrompt, async (result) => { if (result.status === 'error') hadPreError = true; const text = agentResultToText(result); if (text) { await deps.sendMessage(text); preOutputSent = true; } // Close stdin on session-update marker — emitted after query completes, // so all results (including multi-result runs) are already written. if (result.status === 'success' && result.result === null) { deps.closeStdin(); } }); if (preResult === 'error' || hadPreError) { logger.warn( { group: groupName, runId }, 'Pre-compact processing failed, aborting session command', ); await deps.sendMessage( `Failed to process messages before ${command}. Try again.`, ); if (preOutputSent) { // Output was already sent — don't retry or it will duplicate. // Advance cursor past pre-compact messages, leave command pending. deps.advanceCursor(preCompactMsgs[preCompactMsgs.length - 1].timestamp); return { handled: true, success: true }; } return { handled: true, success: false }; } } // Forward the literal slash command as the prompt (no XML formatting) await deps.setTyping(true); let hadCmdError = false; const cmdOutput = await deps.runAgent(command, async (result) => { if (result.status === 'error') hadCmdError = true; const text = agentResultToText(result); if (text) await deps.sendMessage(text); }); // Advance cursor to the command — messages AFTER it remain pending for next poll. deps.advanceCursor(cmdMsg.timestamp); deps.resetPairedTask?.(); await deps.setTyping(false); if (cmdOutput === 'error' || hadCmdError) { await deps.sendMessage(`${command} failed. The session is unchanged.`); } return { handled: true, success: true }; }