Skip to content

Commit df113f1

Browse files
committed
Rework hooks to notification-only: no blocking, terminal still works
- on-stuck.sh: sends TG notification when AskUserQuestion fires, passes through - on-approve.sh: sends TG notification for tool uses (Bash/Edit/Write), passes through - Both hooks run notify in background (fire-and-forget) so terminal is never blocked - Add `prompt` command to index.js (inline keyboard support for future use) - Update telegram-recv to also handle callback_query updates - User can approve/answer from terminal OR Telegram — both work
1 parent d580c0a commit df113f1

4 files changed

Lines changed: 227 additions & 30 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,12 @@ A `PreToolUse` hook intercepts `AskUserQuestion` calls:
4343
1. Claude gets stuck and tries to ask a question
4444
2. Hook (`~/.claude/tools/grog/hooks/on-stuck.sh`) intercepts it
4545
3. Question is sent to Telegram via `grog notify` (includes folder name + full path)
46-
4. Hook calls `telegram-recv` and waits ~90s for the user's reply
47-
5. If reply arrives: hook returns it in the `reason` field → Claude continues with the answer
48-
6. If no reply: hook tells Claude to make its best judgment and keep working
46+
4. Hook passes through — the terminal prompt still works normally
47+
5. User can answer in the terminal OR on Telegram
4948

50-
Fully bidirectional — Claude never stops, never needs to manually call `telegram-recv`.
49+
A second hook (`on-approve.sh`) fires for all tool uses (Bash, Edit, Write, etc.) and sends a notification with tool details. The terminal approval prompt still works normally — notifications are informational.
5150

52-
**Requirement:** `telegramBotToken` and `telegramChatId` must be set in `~/.grog/config.json`. If not configured, the hook passes through and `AskUserQuestion` works normally.
51+
**Requirement:** `telegramBotToken` and `telegramChatId` must be set in `~/.grog/config.json`. If not configured, both hooks pass through silently and everything works normally.
5352

5453
## CLI Commands (index.js)
5554

skill/hooks/on-approve.sh

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/bin/bash
2+
3+
# Grog Hook: on-approve (notification only)
4+
# Sends a Telegram notification when Claude wants to use a tool that
5+
# would normally require terminal approval. Does NOT block — the
6+
# terminal yes/no prompt still works normally.
7+
#
8+
# Hook type: PreToolUse (no matcher = catches all tools)
9+
10+
GROG_TOOL="$HOME/.claude/tools/grog/index.js"
11+
GROG_CONFIG="$HOME/.grog/config.json"
12+
13+
# Read the tool input from stdin
14+
INPUT=$(cat)
15+
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
16+
17+
# Skip tools that are always safe / read-only / handled elsewhere
18+
case "$TOOL_NAME" in
19+
Read|Glob|Grep|LSP|WebSearch|WebFetch|Agent|TodoRead|TaskGet|TaskList|TaskOutput|AskUserQuestion)
20+
exit 0
21+
;;
22+
esac
23+
24+
# Check if Telegram is configured
25+
if [ ! -f "$GROG_CONFIG" ]; then
26+
exit 0
27+
fi
28+
29+
HAS_TOKEN=$(jq -r '.telegramBotToken // empty' "$GROG_CONFIG" 2>/dev/null)
30+
HAS_CHAT=$(jq -r '.telegramChatId // empty' "$GROG_CONFIG" 2>/dev/null)
31+
32+
if [ -z "$HAS_TOKEN" ] || [ -z "$HAS_CHAT" ]; then
33+
exit 0
34+
fi
35+
36+
# Build a concise summary
37+
case "$TOOL_NAME" in
38+
Bash)
39+
DETAIL=$(echo "$INPUT" | jq -r '.tool_input.command // "(no command)"' | head -c 300)
40+
SUMMARY="$DETAIL"
41+
;;
42+
Edit)
43+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // "?"')
44+
SUMMARY="Edit $FILE"
45+
;;
46+
Write)
47+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // "?"')
48+
SUMMARY="Write $FILE"
49+
;;
50+
*)
51+
SUMMARY=$(echo "$INPUT" | jq -r '.tool_input | keys | join(", ")' 2>/dev/null || echo "$TOOL_NAME")
52+
;;
53+
esac
54+
55+
CWD=$(pwd)
56+
FOLDER=$(basename "$CWD")
57+
58+
# Send notification in background (fire-and-forget)
59+
node "$GROG_TOOL" notify "🔧 [$FOLDER] $TOOL_NAME
60+
$SUMMARY" >/dev/null 2>&1 &
61+
62+
# Exit 0 = pass through, terminal prompt still works
63+
exit 0

skill/hooks/on-stuck.sh

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
#!/bin/bash
22

3-
# Grog Hook: on-stuck (bidirectional)
4-
# Intercepts AskUserQuestion → sends to Telegram → waits for reply → returns answer to Claude.
3+
# Grog Hook: on-stuck (notification only)
4+
# Sends a Telegram notification when Claude calls AskUserQuestion.
5+
# Does NOT block — the terminal prompt still works normally.
56
#
67
# Hook type: PreToolUse (matcher: AskUserQuestion)
7-
# Input: JSON on stdin with tool_input containing the question
8-
# Output: JSON with decision to block + the Telegram response as context
98

109
GROG_TOOL="$HOME/.claude/tools/grog/index.js"
1110
GROG_CONFIG="$HOME/.grog/config.json"
@@ -28,27 +27,14 @@ if [ -z "$HAS_TOKEN" ] || [ -z "$HAS_CHAT" ]; then
2827
exit 0
2928
fi
3029

31-
# Get current working directory for context
3230
CWD=$(pwd)
3331
FOLDER=$(basename "$CWD")
3432

35-
# Send question to Telegram with folder context
33+
# Send notification in background (fire-and-forget) so we don't block the terminal
3634
node "$GROG_TOOL" notify "🔔 Claude is stuck in [$FOLDER]
3735
📁 $CWD
3836
39-
$QUESTION
37+
$QUESTION" >/dev/null 2>&1 &
4038

41-
Reply here to answer." 2>/dev/null
42-
43-
# Wait for response (one attempt, ~90s)
44-
RESPONSE=$(node "$GROG_TOOL" telegram-recv 2>/dev/null)
45-
46-
if [ "$RESPONSE" = "[no message]" ] || [ -z "$RESPONSE" ]; then
47-
# No reply — block and tell Claude to state the blocker and continue without an answer
48-
ESCAPED=$(echo "No response from Telegram after ~90s. State the blocker clearly in the terminal output, then make your best judgment call and continue working. Do NOT call AskUserQuestion again for the same question." | jq -Rs .)
49-
echo "{\"decision\":\"block\",\"reason\":$ESCAPED}"
50-
else
51-
# Got a reply — pass it back to Claude as context
52-
ESCAPED=$(echo "Telegram response from the user: $RESPONSE" | jq -Rs .)
53-
echo "{\"decision\":\"block\",\"reason\":$ESCAPED}"
54-
fi
39+
# Exit 0 = pass through, terminal prompt still works
40+
exit 0

skill/index.js

Lines changed: 153 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,6 +1338,137 @@ async function handleNotify(args) {
13381338
console.log("> notified via Telegram");
13391339
}
13401340

1341+
/**
1342+
* Send a prompt with inline keyboard buttons to Telegram and wait for response.
1343+
* Used for tool approval (Yes/Yes Always/No) and questions (with Reply option).
1344+
* Returns the callback data or typed text response.
1345+
*/
1346+
async function handlePrompt(args) {
1347+
const chatId = TELEGRAM_CHAT_ID;
1348+
1349+
if (!chatId) {
1350+
console.error("! error: TELEGRAM_CHAT_ID not set");
1351+
process.exit(1);
1352+
}
1353+
1354+
const message = args.join(" ");
1355+
if (!message) {
1356+
console.error("! error: empty message");
1357+
process.exit(1);
1358+
}
1359+
1360+
// Initialize state file if needed
1361+
const state = loadTelegramState();
1362+
if (!state.chatId) {
1363+
const flush = await telegramApi("getUpdates", { timeout: 0 });
1364+
const offset = flush.length > 0
1365+
? Math.max(...flush.map((u) => u.update_id)) + 1
1366+
: 0;
1367+
saveTelegramState({ offset, chatId });
1368+
}
1369+
1370+
// Send message with inline keyboard
1371+
await telegramApi("sendMessage", {
1372+
chat_id: chatId,
1373+
text: message,
1374+
reply_markup: {
1375+
inline_keyboard: [
1376+
[
1377+
{ text: "Yes", callback_data: "yes" },
1378+
{ text: "Yes, don't ask again", callback_data: "always" },
1379+
{ text: "No", callback_data: "no" },
1380+
],
1381+
[
1382+
{ text: "Reply with text...", callback_data: "reply" },
1383+
],
1384+
],
1385+
},
1386+
});
1387+
1388+
// Wait for callback_query or text message response (~90s)
1389+
const freshState = loadTelegramState();
1390+
1391+
for (let attempt = 0; attempt < 2; attempt++) {
1392+
const params = {
1393+
timeout: 45,
1394+
allowed_updates: ["callback_query", "message"],
1395+
};
1396+
if (freshState.offset) params.offset = freshState.offset;
1397+
1398+
const updates = await telegramApi("getUpdates", params);
1399+
1400+
// Advance offset for ALL updates
1401+
if (updates.length > 0) {
1402+
freshState.offset = Math.max(...updates.map((u) => u.update_id)) + 1;
1403+
saveTelegramState(freshState);
1404+
}
1405+
1406+
// Check for callback query (button press) from our chat
1407+
const callback = updates.find(
1408+
(u) => u.callback_query && String(u.callback_query.message?.chat?.id) === String(chatId),
1409+
);
1410+
1411+
if (callback) {
1412+
const data = callback.callback_query.data;
1413+
1414+
// Acknowledge the callback to remove the loading spinner
1415+
await telegramApi("answerCallbackQuery", {
1416+
callback_query_id: callback.callback_query.id,
1417+
});
1418+
1419+
if (data === "reply") {
1420+
// User wants to type a response — send a follow-up and wait for text
1421+
await telegramApi("sendMessage", {
1422+
chat_id: chatId,
1423+
text: "Type your response:",
1424+
reply_markup: { force_reply: true },
1425+
});
1426+
1427+
// Wait for the text message
1428+
for (let textAttempt = 0; textAttempt < 2; textAttempt++) {
1429+
const textParams = { timeout: 45, allowed_updates: ["message"] };
1430+
if (freshState.offset) textParams.offset = freshState.offset;
1431+
1432+
const textUpdates = await telegramApi("getUpdates", textParams);
1433+
1434+
if (textUpdates.length > 0) {
1435+
freshState.offset = Math.max(...textUpdates.map((u) => u.update_id)) + 1;
1436+
saveTelegramState(freshState);
1437+
}
1438+
1439+
const textMsg = textUpdates.find(
1440+
(u) => u.message && String(u.message.chat.id) === String(chatId),
1441+
);
1442+
1443+
if (textMsg) {
1444+
console.log(`reply:${textMsg.message.text || ""}`);
1445+
return;
1446+
}
1447+
}
1448+
1449+
console.log("[no message]");
1450+
return;
1451+
}
1452+
1453+
// Button press: yes, always, no
1454+
console.log(data);
1455+
return;
1456+
}
1457+
1458+
// Check for regular text message (user typed instead of pressing button)
1459+
const textMsg = updates.find(
1460+
(u) => u.message && String(u.message.chat.id) === String(chatId),
1461+
);
1462+
1463+
if (textMsg) {
1464+
console.log(`reply:${textMsg.message.text || ""}`);
1465+
return;
1466+
}
1467+
}
1468+
1469+
console.log("[no message]");
1470+
}
1471+
13411472
/**
13421473
* Wait for a message from Telegram — long-polls for ~90 seconds
13431474
*/
@@ -1352,14 +1483,15 @@ async function handleTelegramRecv() {
13521483

13531484
// Poll with retry — up to ~90 seconds before returning [no message]
13541485
for (let attempt = 0; attempt < 2; attempt++) {
1355-
const params = { timeout: 45, allowed_updates: ["message"] };
1486+
const params = { timeout: 45, allowed_updates: ["message", "callback_query"] };
13561487
if (state.offset) params.offset = state.offset;
13571488

13581489
const updates = await telegramApi("getUpdates", params);
13591490

1360-
// Filter for our chat only
1491+
// Filter for our chat only (messages and callback queries)
13611492
const messages = updates.filter(
1362-
(u) => u.message && String(u.message.chat.id) === String(chatId),
1493+
(u) => (u.message && String(u.message.chat.id) === String(chatId)) ||
1494+
(u.callback_query && String(u.callback_query.message?.chat?.id) === String(chatId)),
13631495
);
13641496

13651497
// Advance offset for ALL updates (including other chats)
@@ -1370,7 +1502,13 @@ async function handleTelegramRecv() {
13701502

13711503
if (messages.length > 0) {
13721504
const texts = messages
1373-
.map((u) => u.message.text || "[non-text message]")
1505+
.map((u) => {
1506+
if (u.callback_query) {
1507+
telegramApi("answerCallbackQuery", { callback_query_id: u.callback_query.id }).catch(() => {});
1508+
return u.callback_query.data;
1509+
}
1510+
return u.message.text || "[non-text message]";
1511+
})
13741512
.join("\n");
13751513
console.log(texts);
13761514
return;
@@ -1479,6 +1617,17 @@ async function main() {
14791617
break;
14801618
}
14811619

1620+
case "prompt": {
1621+
const promptArgs = process.argv.slice(3);
1622+
if (promptArgs.length === 0) {
1623+
console.error("! error: missing message");
1624+
console.log(" usage: grog prompt <message>");
1625+
process.exit(1);
1626+
}
1627+
await handlePrompt(promptArgs);
1628+
break;
1629+
}
1630+
14821631
default:
14831632
// Backwards compatibility: if the argument looks like a URL, auto-detect command
14841633
if (command.includes("github.com") && command.includes("/issues/")) {

0 commit comments

Comments
 (0)