Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
npm run format:fix
git add -u
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/channels/telegram.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
82 changes: 80 additions & 2 deletions src/channels/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,6 +20,7 @@ export interface TelegramChannelOpts {
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
onReset?: (chatJid: string) => boolean;
onGetStatus?: () => string;
}

export class TelegramChannel implements Channel {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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[] = ['<b>Installed skills:</b>'];
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(
`• <code>/${safeName}</code>${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;
Expand Down Expand Up @@ -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)'));
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return new Promise<void>((resolve) => {
this.bot!.start({
onStart: (botInfo) => {
Expand Down
1 change: 1 addition & 0 deletions src/container-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 1 addition & 2 deletions src/container-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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' });
}

Expand Down
38 changes: 37 additions & 1 deletion src/group-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,14 +283,50 @@ 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) {
logger.error(
{ groupJid, retryCount: state.retryCount },
'Max retries exceeded, dropping messages (will retry on next incoming message)',
);
state.retryCount = 0;
return;
}

Expand Down
115 changes: 110 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -54,6 +59,7 @@ let sessions: Record<string, string> = {};
let registeredGroups: Record<string, RegisteredGroup> = {};
let lastAgentTimestamp: Record<string, string> = {};
let messageLoopRunning = false;
const startTime = Date.now();

let whatsapp: WhatsAppChannel;
const channels: Channel[] = [];
Expand Down Expand Up @@ -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!));
}
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
wrappedOnOutput,
);

Expand Down Expand Up @@ -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);
Comment on lines +497 to +529
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Validate and identify persisted agent PIDs before cleanup.

readAgentPids() trusts raw JSON.parse() output, and cleanupOrphanedAgents() later iterates and SIGKILLs whatever numbers come back. A valid-but-wrong payload can crash startup, 0/negative values have process-group semantics on POSIX, and a reused PID can target an unrelated host process. Parse into unknown, accept only positive integers, and verify the record still belongs to a GhostClaw agent before killing it.

Based on learnings: "Run agents directly on the host machine as Node.js child processes without containerization".

Also applies to: 522-537

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 492 - 519, readAgentPids currently trusts
JSON.parse and may return unsafe values; change it to parse into unknown, ensure
the result is an array of positive integers (reject 0/negatives/non-integers),
dedupe and return only valid PIDs; update writeAgentPids to sanitize the pids
array before persisting (use the same validation). In cleanupOrphanedAgents (and
related logic around lines referenced) verify each PID still belongs to a
GhostClaw agent before sending SIGKILL by checking the process command/argv
(e.g., /proc/<pid>/cmdline on POSIX or platform-appropriate process listing),
skip invalid or non-matching PIDs, and handle all errors without throwing;
ensure trackAgentPid/untrackAgentPid use the validated read/write helpers so
only safe PIDs are stored and acted upon.

}

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<void> {
acquirePidLock();
cleanupOrphanedAgents();

const errorsLog = path.join(process.cwd(), 'logs', 'errors.log');
try {
Expand Down Expand Up @@ -531,6 +601,36 @@ async function main(): Promise<void> {
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 = [
`<b>GhostClaw status</b>`,
`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(', ')}`);
Comment on lines +622 to +628
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the file and get context around lines 614-620
wc -l src/index.ts

Repository: b1rdmania/ghostclaw

Length of output: 79


🏁 Script executed:

# Read the relevant section with surrounding context
sed -n '600,650p' src/index.ts

Repository: b1rdmania/ghostclaw

Length of output: 1615


🏁 Script executed:

# Search for where the 'lines' variable is used and how the status message is constructed
rg -n "lines\." src/index.ts | head -20

Repository: b1rdmania/ghostclaw

Length of output: 185


🏁 Script executed:

# Look for parse_mode usage to understand if HTML mode is being used
rg -n "parse_mode" src/index.ts

Repository: b1rdmania/ghostclaw

Length of output: 45


🏁 Script executed:

# Search for any HTML escaping utilities in the codebase
rg -n "escapeHtml|htmlEscape|escapeXml" src/

Repository: b1rdmania/ghostclaw

Length of output: 1323


🏁 Script executed:

# Let's get more context to identify the function containing this code
sed -n '575,625p' src/index.ts

Repository: b1rdmania/ghostclaw

Length of output: 1642


🏁 Script executed:

# Search for where this function result is used and what parse_mode is set
rg -n "sendMessage\|parse_mode" src/index.ts | head -20

Repository: b1rdmania/ghostclaw

Length of output: 45


🏁 Script executed:

# Look for TelegramChannel class definition to understand parse_mode handling
rg -n "class TelegramChannel\|parse_mode.*HTML" src/

Repository: b1rdmania/ghostclaw

Length of output: 45


🏁 Script executed:

# Search for onGetStatus usage/calls
rg -n "onGetStatus" src/

Repository: b1rdmania/ghostclaw

Length of output: 262


🏁 Script executed:

# Find TelegramChannel class and implementation
rg -n "class TelegramChannel" src/

Repository: b1rdmania/ghostclaw

Length of output: 140


🏁 Script executed:

# Search for sendMessage implementation
rg -n "sendMessage\(" src/ -A 2

Repository: b1rdmania/ghostclaw

Length of output: 8602


🏁 Script executed:

# Let's see the telegram.ts sendMessage implementation more clearly
sed -n '392,430p' src/channels/telegram.ts

Repository: b1rdmania/ghostclaw

Length of output: 1072


🏁 Script executed:

# And check where onGetStatus is called in telegram.ts
sed -n '100,130p' src/channels/telegram.ts

Repository: b1rdmania/ghostclaw

Length of output: 1102


🏁 Script executed:

# Verify escapeHtml function in router.ts
sed -n '93,102p' src/router.ts

Repository: b1rdmania/ghostclaw

Length of output: 364


🏁 Script executed:

# Check the markdownToTelegramHtml function to understand how it handles escaping
rg -n "markdownToTelegramHtml" src/ -A 10

Repository: b1rdmania/ghostclaw

Length of output: 7576


🏁 Script executed:

# Let's also verify the exact path where onGetStatus is called in telegram.ts
sed -n '105,115p' src/channels/telegram.ts

Repository: b1rdmania/ghostclaw

Length of output: 420


🏁 Script executed:

# Let's verify there's no escaping happening at the ctx.reply level
sed -n '1,50p' src/channels/telegram.ts | grep -i "import\|from"

Repository: b1rdmania/ghostclaw

Length of output: 553


🏁 Script executed:

# Check if ctx.reply is from grammyjs and if it does any escaping
rg -n "ctx.reply" src/channels/telegram.ts | head -5

Repository: b1rdmania/ghostclaw

Length of output: 250


Escape dynamic group labels in the HTML status message body.

The group.name and g.jid values interpolated at lines 614–620 are sent directly into a Telegram message with parse_mode: 'HTML' (line 111 of telegram.ts). A group title or JID containing <, >, or & will be parsed as HTML, allowing an attacker to inject formatting or break the message layout. Use the existing escapeHtml() function (available from router.js) to escape these values before interpolation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 614 - 620, The group label interpolation uses
unescaped values (registeredGroups, group?.name and g.jid) when building the
status lines and sending HTML to Telegram; wrap both group?.name and g.jid with
the existing escapeHtml(...) helper from router.js before composing name and
before calling lines.push (i.e., compute name = escapeHtml(group?.name || g.jid)
or escape both individually) so any '<', '>' or '&' are encoded prior to
lines.push and subsequent send with parse_mode: 'HTML'.

}
}

return lines.join('\n');
},
};

if (TELEGRAM_BOT_TOKEN) {
Expand Down Expand Up @@ -564,8 +664,13 @@ async function main(): Promise<void> {
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) {
Expand Down
Loading