Skip to content

Commit 59c8382

Browse files
b1rdmaniaclaude
andauthored
v0.6.0 — reliability + remote control
* feat: v0.6.0 — reliability + remote control - Fix infinite retry loop: scheduleRetry no longer resets retryCount after MAX_RETRIES - Orphan PID cleanup on startup: track agent PIDs in data/agent-pids.json, kill survivors on boot - /status command: active agents, queue depth, uptime via Telegram - /skills command: lists installed skills with descriptions from .claude/skills/ - setMyCommands() at startup: Telegram command menu auto-populated - GroupQueue.getStatus(): exposes live queue state for external consumers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: apply prettier formatting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address CodeRabbit review comments - Make /skills handler async; await all ctx.reply() calls to preserve chunk order - Escape skill dir name and description with escapeXml() before HTML interpolation - Escape group name/JID in /status output with escapeXml() - Mirror PID tracking on scheduler onProcess hook (scheduled tasks were not tracked) - Validate agent-pids.json before killing: accept only positive integers, ignore malformed entries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: restage prettier-formatted files Pre-commit hook was reformatting but not re-staging, causing committed versions to diverge from what prettier --check expects. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add setMyCommands mock to telegram tests; fix husky hook to restage formatted files - telegram.test.ts: add setMyCommands vi.fn() to the mock bot api so connect() doesn't throw - .husky/pre-commit: add 'git add -u' after format:fix so prettier-reformatted files are included in the commit rather than left as unstaged changes (was causing CI format checks to fail) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve all pre-existing test failures - container-runner.ts: replace dynamic require('child_process') with static import so vi.mock() intercepts execSync in tests - container-runner.test.ts: add execSync mock to child_process stub - telegram.test.ts: add setMyCommands to mock bot api (already in previous commit) All 32 test files, 448 tests now pass locally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0492f03 commit 59c8382

9 files changed

Lines changed: 244 additions & 11 deletions

File tree

.husky/pre-commit

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
npm run format:fix
2+
git add -u

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## v0.6.0 (2026-03-19) — Reliability + remote control
4+
5+
### Fixes
6+
- 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.
7+
- 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.
8+
9+
### New
10+
- `/status` command: shows active agents per group, queue depth (pending tasks + messages), and uptime. Available via Telegram.
11+
- `/skills` command: lists all installed skills with descriptions, read live from `.claude/skills/`. Available via Telegram.
12+
- Telegram command menu: `setMyCommands()` called at startup so all commands appear with descriptions when the user types `/`.
13+
- `GroupQueue.getStatus()`: exposes live queue state (active count, waiting groups, per-group task/message queues) for external consumers.
14+
315
## v0.5.5 (2026-03-18) — Remote control hotfix
416

517
### Fixes

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ghostclaw",
3-
"version": "0.5.5",
3+
"version": "0.6.0",
44
"description": "Personal AI assistant. Bare metal, Telegram-first, no containers.",
55
"type": "module",
66
"main": "dist/index.js",

src/channels/telegram.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ vi.mock('grammy', () => ({
3434
api = {
3535
sendMessage: vi.fn().mockResolvedValue(undefined),
3636
sendChatAction: vi.fn().mockResolvedValue(undefined),
37+
setMyCommands: vi.fn().mockResolvedValue(undefined),
3738
};
3839

3940
constructor(token: string) {

src/channels/telegram.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
77
import { logger } from '../logger.js';
88
import { signalNewMessage } from '../message-signal.js';
99
import { transcribeBuffer, textToSpeech } from '../transcription.js';
10-
import { markdownToTelegramHtml } from '../router.js';
10+
import { markdownToTelegramHtml, escapeXml } from '../router.js';
1111
import {
1212
Channel,
1313
OnChatMetadata,
@@ -20,6 +20,7 @@ export interface TelegramChannelOpts {
2020
onChatMetadata: OnChatMetadata;
2121
registeredGroups: () => Record<string, RegisteredGroup>;
2222
onReset?: (chatJid: string) => boolean;
23+
onGetStatus?: () => string;
2324
}
2425

2526
export class TelegramChannel implements Channel {
@@ -66,7 +67,9 @@ export class TelegramChannel implements Channel {
6667
return;
6768
}
6869
this.opts.onReset?.(chatJid);
69-
ctx.reply('Reset. Agent killed and queue cleared — send me something to start fresh.');
70+
ctx.reply(
71+
'Reset. Agent killed and queue cleared — send me something to start fresh.',
72+
);
7073
});
7174

7275
// Command to pull latest code and restart
@@ -97,6 +100,66 @@ export class TelegramChannel implements Channel {
97100
}
98101
});
99102

103+
// Command to show active agents, queue depth, and uptime
104+
this.bot.command('status', (ctx) => {
105+
const chatJid = `tg:${ctx.chat.id}`;
106+
const group = this.opts.registeredGroups()[chatJid];
107+
if (!group) {
108+
ctx.reply('Not a registered chat.');
109+
return;
110+
}
111+
const text = this.opts.onGetStatus?.() ?? 'Status unavailable.';
112+
ctx.reply(text, { parse_mode: 'HTML' });
113+
});
114+
115+
// Command to list installed skills
116+
this.bot.command('skills', async (ctx) => {
117+
const chatJid = `tg:${ctx.chat.id}`;
118+
const group = this.opts.registeredGroups()[chatJid];
119+
if (!group) {
120+
await ctx.reply('Not a registered chat.');
121+
return;
122+
}
123+
const skillsDir = path.join(process.cwd(), '.claude', 'skills');
124+
if (!fs.existsSync(skillsDir)) {
125+
await ctx.reply('No skills directory found.');
126+
return;
127+
}
128+
const lines: string[] = ['<b>Installed skills:</b>'];
129+
const dirs = fs.readdirSync(skillsDir).sort();
130+
for (const dir of dirs) {
131+
const stat = fs.statSync(path.join(skillsDir, dir));
132+
if (!stat.isDirectory()) continue;
133+
const skillMd = path.join(skillsDir, dir, 'SKILL.md');
134+
if (!fs.existsSync(skillMd)) continue;
135+
const content = fs.readFileSync(skillMd, 'utf-8');
136+
const descMatch = content.match(/^description:\s*(.+)$/m);
137+
const desc = descMatch ? descMatch[1].trim() : '';
138+
const safeName = escapeXml(dir);
139+
const safeDesc = desc ? escapeXml(desc.slice(0, 80)) : '';
140+
lines.push(
141+
`• <code>/${safeName}</code>${safeDesc ? ` — ${safeDesc}` : ''}`,
142+
);
143+
}
144+
const text = lines.length > 1 ? lines.join('\n') : 'No skills installed.';
145+
// Chunk if needed — Telegram 4096 char limit
146+
const MAX = 4096;
147+
if (text.length <= MAX) {
148+
await ctx.reply(text, { parse_mode: 'HTML' });
149+
} else {
150+
let chunk = '';
151+
for (const line of lines) {
152+
if (chunk.length + line.length + 1 > MAX) {
153+
await ctx.reply(chunk, { parse_mode: 'HTML' });
154+
chunk = line;
155+
} else {
156+
chunk = chunk ? `${chunk}\n${line}` : line;
157+
}
158+
}
159+
if (chunk) await ctx.reply(chunk, { parse_mode: 'HTML' });
160+
}
161+
});
162+
100163
this.bot.on('message:text', async (ctx) => {
101164
// Skip commands
102165
if (ctx.message.text.startsWith('/')) return;
@@ -296,6 +359,21 @@ export class TelegramChannel implements Channel {
296359
logger.error({ err: err.message }, 'Telegram bot error');
297360
});
298361

362+
// Register commands in Telegram's menu (shows when user types /)
363+
await this.bot.api
364+
.setMyCommands([
365+
{ command: 'ping', description: 'Check the bot is online' },
366+
{
367+
command: 'status',
368+
description: 'Active agents, queue depth, uptime',
369+
},
370+
{ command: 'skills', description: 'List installed skills' },
371+
{ command: 'reset', description: 'Kill stalled agent and clear queue' },
372+
{ command: 'update', description: 'Pull latest code and restart' },
373+
{ command: 'chatid', description: "Get this chat's registration ID" },
374+
])
375+
.catch((err) => logger.warn({ err }, 'setMyCommands failed (non-fatal)'));
376+
299377
return new Promise<void>((resolve) => {
300378
this.bot!.start({
301379
onStart: (botInfo) => {

src/container-runner.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ vi.mock('child_process', async () => {
7171
return {
7272
...actual,
7373
spawn: vi.fn(() => fakeProc),
74+
execSync: vi.fn(() => ''),
7475
exec: vi.fn(
7576
(_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => {
7677
if (cb) cb(null);

src/container-runner.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Agent Runner for GhostClaw
33
* Spawns agent execution as direct Node.js processes (no containers)
44
*/
5-
import { ChildProcess, spawn } from 'child_process';
5+
import { ChildProcess, spawn, execSync } from 'child_process';
66
import fs from 'fs';
77
import path from 'path';
88

@@ -194,7 +194,6 @@ function getAgentRunnerEntrypoint(): string {
194194

195195
if (!fs.existsSync(distEntry)) {
196196
logger.info('Agent runner not compiled, compiling now...');
197-
const { execSync } = require('child_process');
198197
execSync('npx tsc', { cwd: agentRunnerRoot, stdio: 'pipe' });
199198
}
200199

src/group-queue.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,14 +283,50 @@ export class GroupQueue {
283283
}
284284
}
285285

286+
getStatus(): {
287+
active: number;
288+
waiting: number;
289+
groups: {
290+
jid: string;
291+
active: boolean;
292+
queuedTasks: number;
293+
queuedMessages: boolean;
294+
}[];
295+
} {
296+
const groups: {
297+
jid: string;
298+
active: boolean;
299+
queuedTasks: number;
300+
queuedMessages: boolean;
301+
}[] = [];
302+
for (const [jid, state] of this.groups) {
303+
if (
304+
state.active ||
305+
state.pendingTasks.length > 0 ||
306+
state.pendingMessages
307+
) {
308+
groups.push({
309+
jid,
310+
active: state.active,
311+
queuedTasks: state.pendingTasks.length,
312+
queuedMessages: state.pendingMessages,
313+
});
314+
}
315+
}
316+
return {
317+
active: this.activeCount,
318+
waiting: this.waitingGroups.length,
319+
groups,
320+
};
321+
}
322+
286323
private scheduleRetry(groupJid: string, state: GroupState): void {
287324
state.retryCount++;
288325
if (state.retryCount > MAX_RETRIES) {
289326
logger.error(
290327
{ groupJid, retryCount: state.retryCount },
291328
'Max retries exceeded, dropping messages (will retry on next incoming message)',
292329
);
293-
state.retryCount = 0;
294330
return;
295331
}
296332

src/index.ts

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ import {
3939
import { GroupQueue } from './group-queue.js';
4040
import { resolveGroupFolderPath } from './group-folder.js';
4141
import { startIpcWatcher } from './ipc.js';
42-
import { findChannel, formatMessages, formatOutbound } from './router.js';
42+
import {
43+
findChannel,
44+
formatMessages,
45+
formatOutbound,
46+
escapeXml,
47+
} from './router.js';
4348
import { startSchedulerLoop } from './task-scheduler.js';
4449
import { Channel, NewMessage, RegisteredGroup } from './types.js';
4550
import { logger } from './logger.js';
@@ -54,6 +59,7 @@ let sessions: Record<string, string> = {};
5459
let registeredGroups: Record<string, RegisteredGroup> = {};
5560
let lastAgentTimestamp: Record<string, string> = {};
5661
let messageLoopRunning = false;
62+
const startTime = Date.now();
5763

5864
let whatsapp: WhatsAppChannel;
5965
const channels: Channel[] = [];
@@ -307,8 +313,13 @@ async function runAgent(
307313
isMain,
308314
assistantName: ASSISTANT_NAME,
309315
},
310-
(proc, containerName) =>
311-
queue.registerProcess(chatJid, proc, containerName, group.folder),
316+
(proc, containerName) => {
317+
queue.registerProcess(chatJid, proc, containerName, group.folder);
318+
if (proc.pid) {
319+
trackAgentPid(proc.pid);
320+
proc.once('exit', () => untrackAgentPid(proc.pid!));
321+
}
322+
},
312323
wrappedOnOutput,
313324
);
314325

@@ -481,8 +492,67 @@ function releasePidLock(): void {
481492
}
482493
}
483494

495+
const agentPidsFile = path.join(DATA_DIR, 'agent-pids.json');
496+
497+
function readAgentPids(): number[] {
498+
try {
499+
const raw: unknown = JSON.parse(fs.readFileSync(agentPidsFile, 'utf-8'));
500+
if (!Array.isArray(raw)) return [];
501+
// Accept only positive integers — 0/negative have process-group semantics on POSIX
502+
return raw.filter(
503+
(v): v is number => typeof v === 'number' && Number.isInteger(v) && v > 0,
504+
);
505+
} catch {
506+
return [];
507+
}
508+
}
509+
510+
function writeAgentPids(pids: number[]): void {
511+
try {
512+
fs.mkdirSync(DATA_DIR, { recursive: true });
513+
fs.writeFileSync(agentPidsFile, JSON.stringify(pids));
514+
} catch {
515+
/* ignore */
516+
}
517+
}
518+
519+
function trackAgentPid(pid: number): void {
520+
const pids = readAgentPids();
521+
if (!pids.includes(pid)) {
522+
pids.push(pid);
523+
writeAgentPids(pids);
524+
}
525+
}
526+
527+
function untrackAgentPid(pid: number): void {
528+
const pids = readAgentPids().filter((p) => p !== pid);
529+
writeAgentPids(pids);
530+
}
531+
532+
function cleanupOrphanedAgents(): void {
533+
const pids = readAgentPids();
534+
if (pids.length === 0) return;
535+
536+
let killed = 0;
537+
for (const pid of pids) {
538+
try {
539+
process.kill(pid, 0); // throws if dead
540+
process.kill(pid, 'SIGKILL');
541+
killed++;
542+
logger.warn({ pid }, 'Killed orphaned agent process from previous run');
543+
} catch {
544+
/* already dead */
545+
}
546+
}
547+
writeAgentPids([]);
548+
if (killed > 0) {
549+
logger.info({ killed }, 'Orphan agent cleanup complete');
550+
}
551+
}
552+
484553
async function main(): Promise<void> {
485554
acquirePidLock();
555+
cleanupOrphanedAgents();
486556

487557
const errorsLog = path.join(process.cwd(), 'logs', 'errors.log');
488558
try {
@@ -531,6 +601,36 @@ async function main(): Promise<void> {
531601
queue.clearQueue(chatJid);
532602
return queue.killAgent(chatJid);
533603
},
604+
onGetStatus: () => {
605+
const status = queue.getStatus();
606+
const uptimeMs = Date.now() - startTime;
607+
const uptimeMin = Math.floor(uptimeMs / 60000);
608+
const uptimeHr = Math.floor(uptimeMin / 60);
609+
const uptime =
610+
uptimeHr > 0 ? `${uptimeHr}h ${uptimeMin % 60}m` : `${uptimeMin}m`;
611+
612+
const lines = [
613+
`<b>GhostClaw status</b>`,
614+
`Uptime: ${uptime}`,
615+
`Active agents: ${status.active}`,
616+
`Waiting groups: ${status.waiting}`,
617+
];
618+
619+
if (status.groups.length > 0) {
620+
lines.push('');
621+
for (const g of status.groups) {
622+
const group = registeredGroups[g.jid];
623+
const name = escapeXml(group?.name || g.jid);
624+
const parts: string[] = [];
625+
if (g.active) parts.push('running');
626+
if (g.queuedTasks > 0) parts.push(`${g.queuedTasks} task(s) queued`);
627+
if (g.queuedMessages) parts.push('messages queued');
628+
lines.push(`• ${name}: ${parts.join(', ')}`);
629+
}
630+
}
631+
632+
return lines.join('\n');
633+
},
534634
};
535635

536636
if (TELEGRAM_BOT_TOKEN) {
@@ -564,8 +664,13 @@ async function main(): Promise<void> {
564664
registeredGroups: () => registeredGroups,
565665
getSessions: () => sessions,
566666
queue,
567-
onProcess: (groupJid, proc, containerName, groupFolder) =>
568-
queue.registerProcess(groupJid, proc, containerName, groupFolder),
667+
onProcess: (groupJid, proc, containerName, groupFolder) => {
668+
queue.registerProcess(groupJid, proc, containerName, groupFolder);
669+
if (proc.pid) {
670+
trackAgentPid(proc.pid);
671+
proc.once('exit', () => untrackAgentPid(proc.pid!));
672+
}
673+
},
569674
sendMessage: async (jid, rawText) => {
570675
const channel = findChannel(channels, jid);
571676
if (!channel) {

0 commit comments

Comments
 (0)