Skip to content

Commit 7bc905c

Browse files
ziggyclaude
andcommitted
Add /reset command to force-kill stalled agents from Telegram
Type /reset in any registered chat to kill a stuck agent process. Next message spawns a fresh one. No SSH needed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7e6cacc commit 7bc905c

5 files changed

Lines changed: 90 additions & 1 deletion

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,18 @@ systemctl --user start ghostclaw
136136
systemctl --user restart ghostclaw
137137
```
138138

139+
## Telegram commands
140+
141+
These work in any registered Telegram chat:
142+
143+
| Command | What it does |
144+
|---------|-------------|
145+
| `/reset` | Force-kills a stalled agent. Next message starts fresh. |
146+
| `/ping` | Check if the bot is online. |
147+
| `/chatid` | Show the chat's registration ID. |
148+
149+
`/reset` is the one you'll use most — if the bot seems stuck or unresponsive, type `/reset` and send another message.
150+
139151
## Updating
140152

141153
```

groups/global/CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ Keep it to one short line. Don't ack simple questions you can answer directly.
2929

3030
If the request is ambiguous or you need clarification, ask via `send_message` right away instead of guessing.
3131

32+
### If you're stalled
33+
34+
If the user types `/reset` in Telegram, your process gets force-killed and a fresh agent spawns on the next message. This is normal — don't try to prevent it.
35+
3236
### Internal thoughts
3337

3438
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:

src/channels/telegram.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { Bot, InputFile } from 'grammy';
2+
import path from 'path';
3+
import fs from 'fs';
24

35
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
46
import { logger } from '../logger.js';
@@ -16,6 +18,7 @@ export interface TelegramChannelOpts {
1618
onMessage: OnInboundMessage;
1719
onChatMetadata: OnChatMetadata;
1820
registeredGroups: () => Record<string, RegisteredGroup>;
21+
onReset?: (chatJid: string) => boolean;
1922
}
2023

2124
export class TelegramChannel implements Channel {
@@ -53,6 +56,22 @@ export class TelegramChannel implements Channel {
5356
ctx.reply(`${ASSISTANT_NAME} is online.`);
5457
});
5558

59+
// Command to force-kill a stalled agent and start fresh
60+
this.bot.command('reset', (ctx) => {
61+
const chatJid = `tg:${ctx.chat.id}`;
62+
const group = this.opts.registeredGroups()[chatJid];
63+
if (!group) {
64+
ctx.reply('Not a registered chat.');
65+
return;
66+
}
67+
const killed = this.opts.onReset?.(chatJid) ?? false;
68+
if (killed) {
69+
ctx.reply('Reset. Agent killed — send me something to start fresh.');
70+
} else {
71+
ctx.reply('Nothing running. Send me something to start.');
72+
}
73+
});
74+
5675
this.bot.on('message:text', async (ctx) => {
5776
// Skip commands
5877
if (ctx.message.text.startsWith('/')) return;
@@ -168,7 +187,47 @@ export class TelegramChannel implements Channel {
168187
signalNewMessage();
169188
};
170189

171-
this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]'));
190+
this.bot.on('message:photo', async (ctx) => {
191+
const chatJid = `tg:${ctx.chat.id}`;
192+
const group = this.opts.registeredGroups()[chatJid];
193+
194+
// Download and save photo
195+
let placeholder = '[Photo]';
196+
try {
197+
const photos = ctx.message.photo;
198+
const largestPhoto = photos[photos.length - 1]; // Get highest resolution
199+
const file = await ctx.api.getFile(largestPhoto.file_id);
200+
const url = `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`;
201+
202+
// Create media directory if needed
203+
const mediaDir = path.join(process.cwd(), 'data', 'telegram-media');
204+
await fs.promises.mkdir(mediaDir, { recursive: true });
205+
206+
// Download photo
207+
const resp = await fetch(url);
208+
if (resp.ok) {
209+
const buffer = Buffer.from(await resp.arrayBuffer());
210+
const timestamp = Date.now();
211+
const filename = `photo_${chatJid.replace(':', '_')}_${timestamp}.jpg`;
212+
const filepath = path.join(mediaDir, filename);
213+
await fs.promises.writeFile(filepath, buffer);
214+
215+
placeholder = `[Photo: ${filepath}]`;
216+
logger.info({ chatJid, filepath, bytes: buffer.length }, 'Downloaded Telegram photo');
217+
218+
// Also save to Desktop for easy access
219+
if (chatJid === 'tg:414798121') { // Main chat
220+
const desktopPath = path.join(process.env.HOME || '', 'Desktop', 'latest-telegram-photo.jpg');
221+
await fs.promises.writeFile(desktopPath, buffer);
222+
logger.info({ desktopPath }, 'Saved photo to Desktop');
223+
}
224+
}
225+
} catch (err) {
226+
logger.error({ err }, 'Telegram photo download error');
227+
}
228+
229+
storeNonText(ctx, placeholder);
230+
});
172231
this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]'));
173232
this.bot.on('message:voice', async (ctx) => {
174233
const chatJid = `tg:${ctx.chat.id}`;

src/group-queue.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,19 @@ export class GroupQueue {
207207
}
208208
}
209209

210+
/**
211+
* Force-kill the active agent process for a group.
212+
* Returns true if a process was killed, false if nothing was running.
213+
*/
214+
killAgent(groupJid: string): boolean {
215+
const state = this.getGroup(groupJid);
216+
if (!state.process || state.process.killed) return false;
217+
218+
logger.info({ groupJid }, 'Force-killing agent process (/reset)');
219+
state.process.kill('SIGKILL');
220+
return true;
221+
}
222+
210223
private async runForGroup(
211224
groupJid: string,
212225
reason: 'messages' | 'drain',

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,7 @@ async function main(): Promise<void> {
575575
isGroup?: boolean,
576576
) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
577577
registeredGroups: () => registeredGroups,
578+
onReset: (chatJid: string) => queue.killAgent(chatJid),
578579
};
579580

580581
// Create and connect channels

0 commit comments

Comments
 (0)