Skip to content

Commit 692b040

Browse files
ziggyclaude
andcommitted
v0.5.1: faster message handling + stall-aware typing
- Event-driven message wake: channels signal the loop immediately instead of waiting for the next poll tick (~2s → <5ms) - Poll interval reduced to 500ms as fallback (was 2000ms) - Typing indicator stops after 15s of no agent output (stall detection), resumes when output flows again - Agents now acknowledge long tasks before starting ("On it — checking X") - Heartbeat: suppress reasoning noise, only report actual problems Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3fd0d80 commit 692b040

8 files changed

Lines changed: 87 additions & 7 deletions

File tree

groups/global/CLAUDE.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,18 @@ You are a personal AI assistant running on a dedicated machine. You help with ta
1616

1717
Your output is sent to the user or group.
1818

19-
You also have `mcp__ghostclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work.
19+
You also have `mcp__ghostclaw__send_message` which sends a message immediately while you're still working.
20+
21+
### Acknowledge before long tasks
22+
23+
If a request will take more than a few seconds (research, multi-step work, file operations, anything involving tools), immediately send a short ack via `send_message` before starting:
24+
- "On it — checking X"
25+
- "Looking into that"
26+
- "Running that now"
27+
28+
Keep it to one short line. Don't ack simple questions you can answer directly.
29+
30+
If the request is ambiguous or you need clarification, ask via `send_message` right away instead of guessing.
2031

2132
### Internal thoughts
2233

groups/main/CLAUDE.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,18 @@ No filler. No "Great question!" No "I'd be happy to help." No emoji unless asked
3232

3333
Output is sent to the user via Telegram (main channel) or WhatsApp (group chats).
3434

35-
`mcp__ghostclaw__send_message` sends a message immediately while you're still working. Useful to acknowledge a request before starting longer work.
35+
`mcp__ghostclaw__send_message` sends a message immediately while you're still working.
36+
37+
### Acknowledge before long tasks
38+
39+
If a request will take more than a few seconds (research, multi-step work, file operations, anything involving tools), immediately send a short ack via `send_message` before starting:
40+
- "On it — checking X"
41+
- "Looking into that"
42+
- "Running that now"
43+
44+
Keep it to one short line. Don't ack simple questions you can answer directly.
45+
46+
If the request is ambiguous or you need clarification, ask via `send_message` right away instead of guessing.
3647

3748
### Telegram Formatting
3849

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.0",
3+
"version": "0.5.1",
44
"description": "Personal AI assistant. Bare metal, Telegram-first, no containers.",
55
"type": "module",
66
"main": "dist/index.js",

src/channels/telegram.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Bot, InputFile } from 'grammy';
22

33
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
44
import { logger } from '../logger.js';
5+
import { signalNewMessage } from '../message-signal.js';
56
import { transcribeBuffer, textToSpeech } from '../transcription.js';
67
import { markdownToTelegramHtml } from '../router.js';
78
import {
@@ -124,6 +125,7 @@ export class TelegramChannel implements Channel {
124125
timestamp,
125126
is_from_me: false,
126127
});
128+
signalNewMessage();
127129

128130
logger.info(
129131
{ chatJid, chatName, sender: senderName },
@@ -163,6 +165,7 @@ export class TelegramChannel implements Channel {
163165
timestamp,
164166
is_from_me: false,
165167
});
168+
signalNewMessage();
166169
};
167170

168171
this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]'));

src/channels/whatsapp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '../config.js';
2020
import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js';
2121
import { logger } from '../logger.js';
22+
import { signalNewMessage } from '../message-signal.js';
2223
import {
2324
isVoiceMessage,
2425
transcribeAudioMessage,
@@ -259,6 +260,7 @@ export class WhatsAppChannel implements Channel {
259260
is_from_me: fromMe,
260261
is_bot_message: isBotMessage,
261262
});
263+
signalNewMessage();
262264
}
263265
}
264266
});

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const ASSISTANT_NAME =
1717
export const ASSISTANT_HAS_OWN_NUMBER =
1818
(process.env.ASSISTANT_HAS_OWN_NUMBER ||
1919
envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
20-
export const POLL_INTERVAL = 2000;
20+
export const POLL_INTERVAL = 500;
2121
export const SCHEDULER_POLL_INTERVAL = 60000;
2222

2323
const PROJECT_ROOT = process.cwd();

src/index.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
TELEGRAM_ONLY,
1212
TRIGGER_PATTERN,
1313
} from './config.js';
14+
import { waitForMessage } from './message-signal.js';
1415
import { TelegramChannel } from './channels/telegram.js';
1516
import { WhatsAppChannel } from './channels/whatsapp.js';
1617
import { initErrorAlerts, sendErrorAlert } from './error-alerts.js';
@@ -195,16 +196,36 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
195196
}, IDLE_TIMEOUT);
196197
};
197198

198-
// Keep typing indicator alive throughout processing (Telegram expires it after 5s)
199+
// Stall-aware typing indicator:
200+
// - Refreshes every 4s (Telegram expires typing after 5s)
201+
// - Stops after 15s of no agent output (stall detection)
202+
// - Resumes when output flows again
203+
const TYPING_STALL_MS = 15_000;
204+
let typingActive = true;
205+
let lastOutputAt = Date.now();
206+
199207
const typingInterval = setInterval(() => {
200-
channel.setTyping?.(chatJid, true)?.catch(() => {});
208+
if (typingActive && Date.now() - lastOutputAt < TYPING_STALL_MS) {
209+
channel.setTyping?.(chatJid, true)?.catch(() => {});
210+
} else if (typingActive) {
211+
// Stalled — stop typing until output resumes
212+
typingActive = false;
213+
channel.setTyping?.(chatJid, false)?.catch(() => {});
214+
}
201215
}, 4000);
202216
await channel.setTyping?.(chatJid, true);
203217

204218
let hadError = false;
205219
let outputSentToUser = false;
206220

207221
const output = await runAgent(group, prompt, chatJid, async (result) => {
222+
// Any streaming output resets the stall timer and resumes typing
223+
lastOutputAt = Date.now();
224+
if (!typingActive) {
225+
typingActive = true;
226+
channel.setTyping?.(chatJid, true)?.catch(() => {});
227+
}
228+
208229
// Streaming output callback — called for each agent result
209230
if (result.result) {
210231
const raw =
@@ -435,7 +456,7 @@ async function startMessageLoop(): Promise<void> {
435456
} catch (err) {
436457
logger.error({ err }, 'Error in message loop');
437458
}
438-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
459+
await waitForMessage(POLL_INTERVAL);
439460
}
440461
}
441462

src/message-signal.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { EventEmitter } from 'events';
2+
3+
/**
4+
* Lightweight signal to wake the message loop immediately when a
5+
* channel receives a message, instead of waiting for the next poll tick.
6+
*/
7+
const emitter = new EventEmitter();
8+
emitter.setMaxListeners(0);
9+
10+
/** Channels call this after storing a message. */
11+
export function signalNewMessage(): void {
12+
emitter.emit('message');
13+
}
14+
15+
/**
16+
* Returns a promise that resolves when either:
17+
* - a new message signal fires, or
18+
* - the timeout expires (fallback poll interval)
19+
*/
20+
export function waitForMessage(timeoutMs: number): Promise<void> {
21+
return new Promise((resolve) => {
22+
const onMessage = () => {
23+
clearTimeout(timer);
24+
resolve();
25+
};
26+
const timer = setTimeout(() => {
27+
emitter.removeListener('message', onMessage);
28+
resolve();
29+
}, timeoutMs);
30+
emitter.once('message', onMessage);
31+
});
32+
}

0 commit comments

Comments
 (0)