From c97f7aac22caa4bca48ce8d7adf9a5427524e016 Mon Sep 17 00:00:00 2001 From: steven1522 <49690359+steven1522@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:46:14 -0500 Subject: [PATCH 1/3] feat: add plugin system with events and hooks Plugin System: - Add src/lib/plugins.ts - auto-discovers plugins from .tinyclaw/plugins/ - Events broadcast to plugin handlers for visualization (3D avatar, etc.) - Hooks transform incoming/outgoing messages per channel - Metadata support (e.g., Telegram MarkdownV2 parseMode) Bug Fixes (found during testing): - Remove message truncation - let channel clients chunk long messages - Strip CLAUDECODE env var so CLI works inside Claude Code sessions Files changed: - src/lib/plugins.ts (new) - src/lib/logging.ts, types.ts, queue-processor.ts (plugin integration) - src/channels/telegram-client.ts (metadata/parseMode support) - src/lib/invoke.ts (CLAUDECODE fix) - .agents/.../send_message.ts (remove truncation) - .gitignore (expanded patterns) --- .../send-user-message/scripts/send_message.ts | 4 +- .gitignore | 35 ++- src/channels/telegram-client.ts | 11 +- src/lib/invoke.ts | 4 + src/lib/logging.ts | 2 + src/lib/plugins.ts | 217 ++++++++++++++++++ src/lib/types.ts | 1 + src/queue-processor.ts | 70 +++--- 8 files changed, 288 insertions(+), 56 deletions(-) create mode 100644 src/lib/plugins.ts diff --git a/.agents/skills/send-user-message/scripts/send_message.ts b/.agents/skills/send-user-message/scripts/send_message.ts index 6411747..84ba635 100644 --- a/.agents/skills/send-user-message/scripts/send_message.ts +++ b/.agents/skills/send-user-message/scripts/send_message.ts @@ -167,9 +167,7 @@ function sendMessage(argv: string[]): void { channel, sender, senderId, - message: message.length > 4000 - ? message.substring(0, 3900) + '\n\n[Message truncated...]' - : message, + message, originalMessage: '', timestamp, messageId, diff --git a/.gitignore b/.gitignore index 4bb0e1e..5d98bfb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,19 +4,42 @@ node_modules/ # TypeScript build output dist/ -# TinyClaw runtime files -.tinyclaw +# Environment variables / secrets +.env +.env.* -.wwebjs_cache -*.log +# Sensitive runtime data +/settings.json +/pairing.json -# Environment variables -.env +# Runtime directories (queue, logs, downloaded files, events) +/queue/ +/logs/ +/files/ +/events/ + +# Plugins (user-installed, not part of source) +/plugins/ + +# Agent state +/.agents/ + +# WhatsApp session data +.wwebjs_cache/ +/whatsapp-session/ +/channels/ + +# Temp / misc runtime +*.log +.update_check +/heartbeat.md # OS files .DS_Store +Thumbs.db # Editor .vscode/ .idea/ *.swp +*~ diff --git a/src/channels/telegram-client.ts b/src/channels/telegram-client.ts index 2af065e..ecd3dd9 100644 --- a/src/channels/telegram-client.ts +++ b/src/channels/telegram-client.ts @@ -67,6 +67,7 @@ interface ResponseData { timestamp: number; messageId: string; files?: string[]; + metadata?: { parseMode?: string; [key: string]: unknown }; } function sanitizeFileName(fileName: string): string { @@ -520,15 +521,17 @@ async function checkOutgoingQueue(): Promise { // Split message if needed (Telegram 4096 char limit) if (responseText) { const chunks = splitMessage(responseText); + const parseMode = responseData.metadata?.parseMode as TelegramBot.ParseMode | undefined; if (chunks.length > 0) { - await bot.sendMessage(targetChatId, chunks[0]!, pending + const opts: TelegramBot.SendMessageOptions = pending ? { reply_to_message_id: pending.messageId } - : {}, - ); + : {}; + if (parseMode) opts.parse_mode = parseMode; + await bot.sendMessage(targetChatId, chunks[0]!, opts); } for (let i = 1; i < chunks.length; i++) { - await bot.sendMessage(targetChatId, chunks[i]!); + await bot.sendMessage(targetChatId, chunks[i]!, parseMode ? { parse_mode: parseMode } : {}); } } diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index 2c21450..24434a7 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -8,9 +8,13 @@ import { ensureAgentDirectory, updateAgentTeammates } from './agent-setup'; export async function runCommand(command: string, args: string[], cwd?: string): Promise { return new Promise((resolve, reject) => { + const env = { ...process.env }; + delete env.CLAUDECODE; + const child = spawn(command, args, { cwd: cwd || SCRIPT_DIR, stdio: ['ignore', 'pipe', 'pipe'], + env, }); let stdout = ''; diff --git a/src/lib/logging.ts b/src/lib/logging.ts index d91baba..08a4dcd 100644 --- a/src/lib/logging.ts +++ b/src/lib/logging.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import { LOG_FILE, EVENTS_DIR } from './config'; +import { broadcastEvent } from './plugins'; export function log(level: string, message: string): void { const timestamp = new Date().toISOString(); @@ -21,6 +22,7 @@ export function emitEvent(type: string, data: Record): void { const event = { type, timestamp: Date.now(), ...data }; const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`; fs.writeFileSync(path.join(EVENTS_DIR, filename), JSON.stringify(event) + '\n'); + broadcastEvent(event); } catch { // Visualizer events are best-effort; never break the queue processor } diff --git a/src/lib/plugins.ts b/src/lib/plugins.ts new file mode 100644 index 0000000..27a7bd2 --- /dev/null +++ b/src/lib/plugins.ts @@ -0,0 +1,217 @@ +/** + * Plugin System for TinyClaw + * + * Plugins auto-discover from .tinyclaw/plugins/ folder. + * Each plugin exports an activate() function and/or a hooks object from index.ts. + */ + +import fs from 'fs'; +import path from 'path'; +import { TINYCLAW_HOME } from './config'; +import { log } from './logging'; + +// Types +export interface PluginEvent { + type: string; + timestamp: number; + [key: string]: unknown; +} + +export interface HookContext { + channel: string; + sender: string; + messageId: string; + originalMessage: string; +} + +export interface HookMetadata { + parseMode?: string; + [key: string]: unknown; +} + +export interface HookResult { + text: string; + metadata: HookMetadata; +} + +export interface Hooks { + transformOutgoing?(message: string, ctx: HookContext): string | HookResult | Promise; + transformIncoming?(message: string, ctx: HookContext): string | HookResult | Promise; +} + +export interface PluginContext { + on(eventType: string | '*', handler: (event: PluginEvent) => void): void; + log(level: string, message: string): void; + getTinyClawHome(): string; +} + +interface LoadedPlugin { + name: string; + hooks?: Hooks; +} + +// Internal state +const loadedPlugins: LoadedPlugin[] = []; +const eventHandlers = new Map void>>(); + +/** + * Create the plugin context passed to activate() functions. + */ +function createPluginContext(pluginName: string): PluginContext { + return { + on(eventType: string, handler: (event: PluginEvent) => void): void { + const handlers = eventHandlers.get(eventType) || []; + handlers.push(handler); + eventHandlers.set(eventType, handlers); + }, + log(level: string, message: string): void { + log(level, `[plugin:${pluginName}] ${message}`); + }, + getTinyClawHome(): string { + return TINYCLAW_HOME; + }, + }; +} + +/** + * Load all plugins from .tinyclaw/plugins/. + * Each plugin directory should have an index.ts/index.js that exports: + * - activate(ctx: PluginContext): void (optional) + * - hooks: Hooks (optional) + */ +export async function loadPlugins(): Promise { + const pluginsDir = path.join(TINYCLAW_HOME, 'plugins'); + + if (!fs.existsSync(pluginsDir)) { + log('DEBUG', 'No plugins directory found'); + return; + } + + const entries = fs.readdirSync(pluginsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const pluginName = entry.name; + const pluginDir = path.join(pluginsDir, pluginName); + + // Try to load index.js or index.ts (compiled) + const indexJs = path.join(pluginDir, 'index.js'); + const indexTs = path.join(pluginDir, 'index.ts'); + + let indexPath: string | null = null; + if (fs.existsSync(indexJs)) { + indexPath = indexJs; + } else if (fs.existsSync(indexTs)) { + indexPath = indexTs; + } + + if (!indexPath) { + log('WARN', `Plugin '${pluginName}' has no index.js or index.ts, skipping`); + continue; + } + + try { + // Dynamic import + const pluginModule = await import(indexPath); + const plugin: LoadedPlugin = { name: pluginName }; + + // Call activate() if present + if (typeof pluginModule.activate === 'function') { + const ctx = createPluginContext(pluginName); + await pluginModule.activate(ctx); + } + + // Store hooks if present + if (pluginModule.hooks) { + plugin.hooks = pluginModule.hooks; + } + + loadedPlugins.push(plugin); + log('INFO', `Loaded plugin: ${pluginName}`); + } catch (error) { + log('ERROR', `Failed to load plugin '${pluginName}': ${(error as Error).message}`); + } + } + + if (loadedPlugins.length > 0) { + log('INFO', `${loadedPlugins.length} plugin(s) loaded`); + } +} + +/** + * Run all transformOutgoing hooks on a message. + */ +export async function runOutgoingHooks(message: string, ctx: HookContext): Promise { + let text = message; + let metadata: HookMetadata = {}; + + for (const plugin of loadedPlugins) { + if (plugin.hooks?.transformOutgoing) { + try { + const result = await plugin.hooks.transformOutgoing(text, ctx); + if (typeof result === 'string') { + text = result; + } else { + text = result.text; + metadata = { ...metadata, ...result.metadata }; + } + } catch (error) { + log('ERROR', `Plugin '${plugin.name}' transformOutgoing error: ${(error as Error).message}`); + } + } + } + + return { text, metadata }; +} + +/** + * Run all transformIncoming hooks on a message. + */ +export async function runIncomingHooks(message: string, ctx: HookContext): Promise { + let text = message; + let metadata: HookMetadata = {}; + + for (const plugin of loadedPlugins) { + if (plugin.hooks?.transformIncoming) { + try { + const result = await plugin.hooks.transformIncoming(text, ctx); + if (typeof result === 'string') { + text = result; + } else { + text = result.text; + metadata = { ...metadata, ...result.metadata }; + } + } catch (error) { + log('ERROR', `Plugin '${plugin.name}' transformIncoming error: ${(error as Error).message}`); + } + } + } + + return { text, metadata }; +} + +/** + * Broadcast an event to all registered handlers. + */ +export function broadcastEvent(event: PluginEvent): void { + // Call specific event type handlers + const typeHandlers = eventHandlers.get(event.type) || []; + for (const handler of typeHandlers) { + try { + handler(event); + } catch (error) { + log('ERROR', `Plugin event handler error: ${(error as Error).message}`); + } + } + + // Call wildcard handlers + const wildcardHandlers = eventHandlers.get('*') || []; + for (const handler of wildcardHandlers) { + try { + handler(event); + } catch (error) { + log('ERROR', `Plugin wildcard handler error: ${(error as Error).message}`); + } + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 985a4c9..cafa018 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -86,6 +86,7 @@ export interface ResponseData { messageId: string; agent?: string; // which agent handled this files?: string[]; + metadata?: Record; } export interface QueueFile { diff --git a/src/queue-processor.ts b/src/queue-processor.ts index 59dcf37..3f83f69 100644 --- a/src/queue-processor.ts +++ b/src/queue-processor.ts @@ -25,6 +25,7 @@ import { import { log, emitEvent } from './lib/logging'; import { parseAgentRouting, findTeamForAgent, getAgentResetFlag, extractTeammateMentions } from './lib/routing'; import { invokeAgent } from './lib/invoke'; +import { loadPlugins, runIncomingHooks, runOutgoingHooks, HookMetadata } from './lib/plugins'; import { jsonrepair } from 'jsonrepair'; /** Parse JSON with automatic repair for malformed content (e.g. bad escapes). */ @@ -51,31 +52,6 @@ const queuedFiles = new Set(); const conversations = new Map(); const MAX_CONVERSATION_MESSAGES = 50; -const LONG_RESPONSE_THRESHOLD = 4000; - -/** - * If a response exceeds the threshold, save full text as a .md file - * and return a truncated preview with the file attached. - */ -function handleLongResponse( - response: string, - existingFiles: string[] -): { message: string; files: string[] } { - if (response.length <= LONG_RESPONSE_THRESHOLD) { - return { message: response, files: existingFiles }; - } - - // Save full response as a .md file - const filename = `response_${Date.now()}.md`; - const filePath = path.join(FILES_DIR, filename); - fs.writeFileSync(filePath, response); - log('INFO', `Long response (${response.length} chars) saved to ${filename}`); - - // Truncate to preview - const preview = response.substring(0, LONG_RESPONSE_THRESHOLD) + '\n\n_(Full response attached as file)_'; - - return { message: preview, files: [...existingFiles, filePath] }; -} // Recover orphaned files from processing/ on startup (crash recovery) function recoverOrphanedFiles() { @@ -131,7 +107,7 @@ function collectFiles(response: string, fileSet: Set): void { /** * Complete a conversation: aggregate responses, write to outgoing queue, save chat history. */ -function completeConversation(conv: Conversation): void { +async function completeConversation(conv: Conversation): Promise { const settings = getSettings(); const agents = getAgents(settings); @@ -203,18 +179,19 @@ function completeConversation(conv: Conversation): void { // Remove [@agent: ...] tags from final response finalResponse = finalResponse.replace(/\[@\S+?:\s*[\s\S]*?\]/g, '').trim(); - // Handle long responses — send as file attachment - const { message: responseMessage, files: allFiles } = handleLongResponse(finalResponse, outboundFiles); + // Run outgoing hooks + const { text: hookedResponse, metadata } = await runOutgoingHooks(finalResponse, { channel: conv.channel, sender: conv.sender, messageId: conv.messageId, originalMessage: conv.originalMessage }); // Write to outgoing queue const responseData: ResponseData = { channel: conv.channel, sender: conv.sender, - message: responseMessage, + message: hookedResponse, originalMessage: conv.originalMessage, timestamp: Date.now(), messageId: conv.messageId, - files: allFiles.length > 0 ? allFiles : undefined, + files: outboundFiles.length > 0 ? outboundFiles : undefined, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, }; const responseFile = conv.channel === 'heartbeat' @@ -351,6 +328,9 @@ async function processMessage(messageFile: string): Promise { } } + // Run incoming hooks + ({ text: message } = await runIncomingHooks(message, { channel, sender, messageId, originalMessage: rawMessage })); + // Invoke agent emitEvent('chain_step_start', { agentId, agentName: agent.name, fromAgent: messageData.fromAgent || null }); let response: string; @@ -377,18 +357,19 @@ async function processMessage(messageFile: string): Promise { finalResponse = finalResponse.replace(/\[send_file:\s*[^\]]+\]/g, '').trim(); } - // Handle long responses — send as file attachment - const { message: responseMessage, files: allFiles } = handleLongResponse(finalResponse, outboundFiles); + // Run outgoing hooks + const { text: hookedResponse, metadata } = await runOutgoingHooks(finalResponse, { channel, sender, messageId, originalMessage: rawMessage }); const responseData: ResponseData = { channel, sender, - message: responseMessage, + message: hookedResponse, originalMessage: rawMessage, timestamp: Date.now(), messageId, agent: agentId, - files: allFiles.length > 0 ? allFiles : undefined, + files: outboundFiles.length > 0 ? outboundFiles : undefined, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, }; const responseFile = channel === 'heartbeat' @@ -462,7 +443,7 @@ async function processMessage(messageFile: string): Promise { conv.pending--; if (conv.pending === 0) { - completeConversation(conv); + await completeConversation(conv); } else { log('INFO', `Conversation ${conv.id}: ${conv.pending} branch(es) still pending`); } @@ -592,14 +573,17 @@ if (!fs.existsSync(EVENTS_DIR)) { } // Main loop -log('INFO', 'Queue processor started'); -recoverOrphanedFiles(); -log('INFO', `Watching: ${QUEUE_INCOMING}`); -logAgentConfig(); -emitEvent('processor_start', { agents: Object.keys(getAgents(getSettings())), teams: Object.keys(getTeams(getSettings())) }); - -// Process queue every 1 second -setInterval(processQueue, 1000); +(async () => { + log('INFO', 'Queue processor started'); + recoverOrphanedFiles(); + await loadPlugins(); + log('INFO', `Watching: ${QUEUE_INCOMING}`); + logAgentConfig(); + emitEvent('processor_start', { agents: Object.keys(getAgents(getSettings())), teams: Object.keys(getTeams(getSettings())) }); + + // Process queue every 1 second + setInterval(processQueue, 1000); +})(); // Graceful shutdown process.on('SIGINT', () => { From 0a2013ae6d79b642020252f2c1e1b3aad77d4fb0 Mon Sep 17 00:00:00 2001 From: steven Date: Fri, 20 Feb 2026 00:28:32 -0500 Subject: [PATCH 2/3] fix: fall back to plain text when MarkdownV2 send fails Prevents messages from getting stuck in an infinite retry loop when Telegram rejects malformed MarkdownV2. On failure, retries without parse_mode so the message still gets delivered. --- src/channels/telegram-client.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/channels/telegram-client.ts b/src/channels/telegram-client.ts index ecd3dd9..3706161 100644 --- a/src/channels/telegram-client.ts +++ b/src/channels/telegram-client.ts @@ -523,15 +523,29 @@ async function checkOutgoingQueue(): Promise { const chunks = splitMessage(responseText); const parseMode = responseData.metadata?.parseMode as TelegramBot.ParseMode | undefined; + const sendChunk = async (text: string, opts: TelegramBot.SendMessageOptions): Promise => { + try { + await bot.sendMessage(targetChatId, text, opts); + } catch (sendErr) { + if (parseMode && opts.parse_mode) { + log('WARN', `MarkdownV2 send failed, falling back to plain text: ${(sendErr as Error).message}`); + delete opts.parse_mode; + await bot.sendMessage(targetChatId, text, opts); + } else { + throw sendErr; + } + } + }; + if (chunks.length > 0) { const opts: TelegramBot.SendMessageOptions = pending ? { reply_to_message_id: pending.messageId } : {}; if (parseMode) opts.parse_mode = parseMode; - await bot.sendMessage(targetChatId, chunks[0]!, opts); + await sendChunk(chunks[0]!, opts); } for (let i = 1; i < chunks.length; i++) { - await bot.sendMessage(targetChatId, chunks[i]!, parseMode ? { parse_mode: parseMode } : {}); + await sendChunk(chunks[i]!, parseMode ? { parse_mode: parseMode } : {}); } } From 1bb58d1c81db8af47ec8dbc99532409fc82f3c6a Mon Sep 17 00:00:00 2001 From: steven Date: Sat, 21 Feb 2026 22:32:40 -0500 Subject: [PATCH 3/3] fix: delete orphaned processing files on startup instead of re-queuing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-queuing orphaned files caused infinite restart loops when the agent triggered a tinyclaw restart — the same message would get recovered and processed again, triggering another restart. Now orphaned files are simply deleted on startup. --- src/queue-processor.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/queue-processor.ts b/src/queue-processor.ts index 3f83f69..9af949f 100644 --- a/src/queue-processor.ts +++ b/src/queue-processor.ts @@ -53,14 +53,14 @@ const conversations = new Map(); const MAX_CONVERSATION_MESSAGES = 50; -// Recover orphaned files from processing/ on startup (crash recovery) +// Clean up orphaned files from processing/ on startup function recoverOrphanedFiles() { for (const f of fs.readdirSync(QUEUE_PROCESSING).filter(f => f.endsWith('.json'))) { try { - fs.renameSync(path.join(QUEUE_PROCESSING, f), path.join(QUEUE_INCOMING, f)); - log('INFO', `Recovered orphaned file: ${f}`); + fs.unlinkSync(path.join(QUEUE_PROCESSING, f)); + log('INFO', `Cleared orphaned file: ${f}`); } catch (error) { - log('ERROR', `Failed to recover orphaned file ${f}: ${(error as Error).message}`); + log('ERROR', `Failed to clear orphaned file ${f}: ${(error as Error).message}`); } } }