diff --git a/.husky/pre-commit b/.husky/pre-commit index 73c726d..209d079 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ npm run format:fix +git add -u diff --git a/CHANGELOG.md b/CHANGELOG.md index dd3cf3e..7e0f1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v0.6.0 (2026-03-19) — Reliability + remote control + +### Fixes +- Infinite retry loop eliminated: `scheduleRetry` no longer resets `retryCount` after `MAX_RETRIES`. Previously, a group that hit max retries would silently reset the counter and retry forever. +- Orphaned agent processes from previous runs are now killed on startup. PIDs are tracked in `data/agent-pids.json` and cleaned up on boot, preventing slot starvation and timeout cascades after a crash or forced restart. + +### New +- `/status` command: shows active agents per group, queue depth (pending tasks + messages), and uptime. Available via Telegram. +- `/skills` command: lists all installed skills with descriptions, read live from `.claude/skills/`. Available via Telegram. +- Telegram command menu: `setMyCommands()` called at startup so all commands appear with descriptions when the user types `/`. +- `GroupQueue.getStatus()`: exposes live queue state (active count, waiting groups, per-group task/message queues) for external consumers. + ## v0.5.5 (2026-03-18) — Remote control hotfix ### Fixes diff --git a/package.json b/package.json index 4d2fe00..f2f2da8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostclaw", - "version": "0.5.5", + "version": "0.6.0", "description": "Personal AI assistant. Bare metal, Telegram-first, no containers.", "type": "module", "main": "dist/index.js", diff --git a/src/channels/telegram.test.ts b/src/channels/telegram.test.ts index d97f3c5..1aaae04 100644 --- a/src/channels/telegram.test.ts +++ b/src/channels/telegram.test.ts @@ -34,6 +34,7 @@ vi.mock('grammy', () => ({ api = { sendMessage: vi.fn().mockResolvedValue(undefined), sendChatAction: vi.fn().mockResolvedValue(undefined), + setMyCommands: vi.fn().mockResolvedValue(undefined), }; constructor(token: string) { diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index f6dc2a3..2ed5e37 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -7,7 +7,7 @@ import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; import { logger } from '../logger.js'; import { signalNewMessage } from '../message-signal.js'; import { transcribeBuffer, textToSpeech } from '../transcription.js'; -import { markdownToTelegramHtml } from '../router.js'; +import { markdownToTelegramHtml, escapeXml } from '../router.js'; import { Channel, OnChatMetadata, @@ -20,6 +20,7 @@ export interface TelegramChannelOpts { onChatMetadata: OnChatMetadata; registeredGroups: () => Record; onReset?: (chatJid: string) => boolean; + onGetStatus?: () => string; } export class TelegramChannel implements Channel { @@ -66,7 +67,9 @@ export class TelegramChannel implements Channel { return; } this.opts.onReset?.(chatJid); - ctx.reply('Reset. Agent killed and queue cleared — send me something to start fresh.'); + ctx.reply( + 'Reset. Agent killed and queue cleared — send me something to start fresh.', + ); }); // Command to pull latest code and restart @@ -97,6 +100,66 @@ export class TelegramChannel implements Channel { } }); + // Command to show active agents, queue depth, and uptime + this.bot.command('status', (ctx) => { + const chatJid = `tg:${ctx.chat.id}`; + const group = this.opts.registeredGroups()[chatJid]; + if (!group) { + ctx.reply('Not a registered chat.'); + return; + } + const text = this.opts.onGetStatus?.() ?? 'Status unavailable.'; + ctx.reply(text, { parse_mode: 'HTML' }); + }); + + // Command to list installed skills + this.bot.command('skills', async (ctx) => { + const chatJid = `tg:${ctx.chat.id}`; + const group = this.opts.registeredGroups()[chatJid]; + if (!group) { + await ctx.reply('Not a registered chat.'); + return; + } + const skillsDir = path.join(process.cwd(), '.claude', 'skills'); + if (!fs.existsSync(skillsDir)) { + await ctx.reply('No skills directory found.'); + return; + } + const lines: string[] = ['Installed skills:']; + const dirs = fs.readdirSync(skillsDir).sort(); + for (const dir of dirs) { + const stat = fs.statSync(path.join(skillsDir, dir)); + if (!stat.isDirectory()) continue; + const skillMd = path.join(skillsDir, dir, 'SKILL.md'); + if (!fs.existsSync(skillMd)) continue; + const content = fs.readFileSync(skillMd, 'utf-8'); + const descMatch = content.match(/^description:\s*(.+)$/m); + const desc = descMatch ? descMatch[1].trim() : ''; + const safeName = escapeXml(dir); + const safeDesc = desc ? escapeXml(desc.slice(0, 80)) : ''; + lines.push( + `• /${safeName}${safeDesc ? ` — ${safeDesc}` : ''}`, + ); + } + const text = lines.length > 1 ? lines.join('\n') : 'No skills installed.'; + // Chunk if needed — Telegram 4096 char limit + const MAX = 4096; + if (text.length <= MAX) { + await ctx.reply(text, { parse_mode: 'HTML' }); + } else { + let chunk = ''; + for (const line of lines) { + if (chunk.length + line.length + 1 > MAX) { + await ctx.reply(chunk, { parse_mode: 'HTML' }); + chunk = line; + } else { + chunk = chunk ? `${chunk}\n${line}` : line; + } + } + if (chunk) await ctx.reply(chunk, { parse_mode: 'HTML' }); + } + }); + this.bot.on('message:text', async (ctx) => { // Skip commands if (ctx.message.text.startsWith('/')) return; @@ -296,6 +359,21 @@ export class TelegramChannel implements Channel { logger.error({ err: err.message }, 'Telegram bot error'); }); + // Register commands in Telegram's menu (shows when user types /) + await this.bot.api + .setMyCommands([ + { command: 'ping', description: 'Check the bot is online' }, + { + command: 'status', + description: 'Active agents, queue depth, uptime', + }, + { command: 'skills', description: 'List installed skills' }, + { command: 'reset', description: 'Kill stalled agent and clear queue' }, + { command: 'update', description: 'Pull latest code and restart' }, + { command: 'chatid', description: "Get this chat's registration ID" }, + ]) + .catch((err) => logger.warn({ err }, 'setMyCommands failed (non-fatal)')); + return new Promise((resolve) => { this.bot!.start({ onStart: (botInfo) => { diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index f17e159..bbec031 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -71,6 +71,7 @@ vi.mock('child_process', async () => { return { ...actual, spawn: vi.fn(() => fakeProc), + execSync: vi.fn(() => ''), exec: vi.fn( (_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => { if (cb) cb(null); diff --git a/src/container-runner.ts b/src/container-runner.ts index 99aab68..5b7e678 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -2,7 +2,7 @@ * Agent Runner for GhostClaw * Spawns agent execution as direct Node.js processes (no containers) */ -import { ChildProcess, spawn } from 'child_process'; +import { ChildProcess, spawn, execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -194,7 +194,6 @@ function getAgentRunnerEntrypoint(): string { if (!fs.existsSync(distEntry)) { logger.info('Agent runner not compiled, compiling now...'); - const { execSync } = require('child_process'); execSync('npx tsc', { cwd: agentRunnerRoot, stdio: 'pipe' }); } diff --git a/src/group-queue.ts b/src/group-queue.ts index 949304d..46cb7dd 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -283,6 +283,43 @@ export class GroupQueue { } } + getStatus(): { + active: number; + waiting: number; + groups: { + jid: string; + active: boolean; + queuedTasks: number; + queuedMessages: boolean; + }[]; + } { + const groups: { + jid: string; + active: boolean; + queuedTasks: number; + queuedMessages: boolean; + }[] = []; + for (const [jid, state] of this.groups) { + if ( + state.active || + state.pendingTasks.length > 0 || + state.pendingMessages + ) { + groups.push({ + jid, + active: state.active, + queuedTasks: state.pendingTasks.length, + queuedMessages: state.pendingMessages, + }); + } + } + return { + active: this.activeCount, + waiting: this.waitingGroups.length, + groups, + }; + } + private scheduleRetry(groupJid: string, state: GroupState): void { state.retryCount++; if (state.retryCount > MAX_RETRIES) { @@ -290,7 +327,6 @@ export class GroupQueue { { groupJid, retryCount: state.retryCount }, 'Max retries exceeded, dropping messages (will retry on next incoming message)', ); - state.retryCount = 0; return; } diff --git a/src/index.ts b/src/index.ts index bb00c62..ee32202 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,7 +39,12 @@ import { import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; -import { findChannel, formatMessages, formatOutbound } from './router.js'; +import { + findChannel, + formatMessages, + formatOutbound, + escapeXml, +} from './router.js'; import { startSchedulerLoop } from './task-scheduler.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; @@ -54,6 +59,7 @@ let sessions: Record = {}; let registeredGroups: Record = {}; let lastAgentTimestamp: Record = {}; let messageLoopRunning = false; +const startTime = Date.now(); let whatsapp: WhatsAppChannel; const channels: Channel[] = []; @@ -307,8 +313,13 @@ async function runAgent( isMain, assistantName: ASSISTANT_NAME, }, - (proc, containerName) => - queue.registerProcess(chatJid, proc, containerName, group.folder), + (proc, containerName) => { + queue.registerProcess(chatJid, proc, containerName, group.folder); + if (proc.pid) { + trackAgentPid(proc.pid); + proc.once('exit', () => untrackAgentPid(proc.pid!)); + } + }, wrappedOnOutput, ); @@ -481,8 +492,67 @@ function releasePidLock(): void { } } +const agentPidsFile = path.join(DATA_DIR, 'agent-pids.json'); + +function readAgentPids(): number[] { + try { + const raw: unknown = JSON.parse(fs.readFileSync(agentPidsFile, 'utf-8')); + if (!Array.isArray(raw)) return []; + // Accept only positive integers — 0/negative have process-group semantics on POSIX + return raw.filter( + (v): v is number => typeof v === 'number' && Number.isInteger(v) && v > 0, + ); + } catch { + return []; + } +} + +function writeAgentPids(pids: number[]): void { + try { + fs.mkdirSync(DATA_DIR, { recursive: true }); + fs.writeFileSync(agentPidsFile, JSON.stringify(pids)); + } catch { + /* ignore */ + } +} + +function trackAgentPid(pid: number): void { + const pids = readAgentPids(); + if (!pids.includes(pid)) { + pids.push(pid); + writeAgentPids(pids); + } +} + +function untrackAgentPid(pid: number): void { + const pids = readAgentPids().filter((p) => p !== pid); + writeAgentPids(pids); +} + +function cleanupOrphanedAgents(): void { + const pids = readAgentPids(); + if (pids.length === 0) return; + + let killed = 0; + for (const pid of pids) { + try { + process.kill(pid, 0); // throws if dead + process.kill(pid, 'SIGKILL'); + killed++; + logger.warn({ pid }, 'Killed orphaned agent process from previous run'); + } catch { + /* already dead */ + } + } + writeAgentPids([]); + if (killed > 0) { + logger.info({ killed }, 'Orphan agent cleanup complete'); + } +} + async function main(): Promise { acquirePidLock(); + cleanupOrphanedAgents(); const errorsLog = path.join(process.cwd(), 'logs', 'errors.log'); try { @@ -531,6 +601,36 @@ async function main(): Promise { queue.clearQueue(chatJid); return queue.killAgent(chatJid); }, + onGetStatus: () => { + const status = queue.getStatus(); + const uptimeMs = Date.now() - startTime; + const uptimeMin = Math.floor(uptimeMs / 60000); + const uptimeHr = Math.floor(uptimeMin / 60); + const uptime = + uptimeHr > 0 ? `${uptimeHr}h ${uptimeMin % 60}m` : `${uptimeMin}m`; + + const lines = [ + `GhostClaw status`, + `Uptime: ${uptime}`, + `Active agents: ${status.active}`, + `Waiting groups: ${status.waiting}`, + ]; + + if (status.groups.length > 0) { + lines.push(''); + for (const g of status.groups) { + const group = registeredGroups[g.jid]; + const name = escapeXml(group?.name || g.jid); + const parts: string[] = []; + if (g.active) parts.push('running'); + if (g.queuedTasks > 0) parts.push(`${g.queuedTasks} task(s) queued`); + if (g.queuedMessages) parts.push('messages queued'); + lines.push(`• ${name}: ${parts.join(', ')}`); + } + } + + return lines.join('\n'); + }, }; if (TELEGRAM_BOT_TOKEN) { @@ -564,8 +664,13 @@ async function main(): Promise { registeredGroups: () => registeredGroups, getSessions: () => sessions, queue, - onProcess: (groupJid, proc, containerName, groupFolder) => - queue.registerProcess(groupJid, proc, containerName, groupFolder), + onProcess: (groupJid, proc, containerName, groupFolder) => { + queue.registerProcess(groupJid, proc, containerName, groupFolder); + if (proc.pid) { + trackAgentPid(proc.pid); + proc.once('exit', () => untrackAgentPid(proc.pid!)); + } + }, sendMessage: async (jid, rawText) => { const channel = findChannel(channels, jid); if (!channel) {