Skip to content

Commit c75e0b0

Browse files
authored
Merge pull request #51 from gHashTag/ralph/w1/telegram-hooks
feat(agent): Telegram hooks + --continue resume + ralph-hook binary
2 parents 1f742cf + bc17650 commit c75e0b0

8 files changed

Lines changed: 364 additions & 38 deletions

File tree

.claude-plugin/hooks/hooks.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
"type": "command",
1313
"command": "ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo '.'); echo \"{\\\"event\\\":\\\"agent_stop\\\",\\\"branch\\\":\\\"$(git branch --show-current 2>/dev/null || echo unknown)\\\",\\\"ts\\\":$(date +%s)}\" >> \"$ROOT/.ralph/god_mode_log.jsonl\" 2>/dev/null || true",
1414
"timeout": 5
15+
},
16+
{
17+
"type": "command",
18+
"command": "test -x \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}/zig-out/bin/ralph-hook\" && echo '{\"hook_event_name\":\"Stop\"}' | \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}/zig-out/bin/ralph-hook\" 2>/dev/null || true",
19+
"timeout": 10
1520
}
1621
]
1722
}
@@ -88,6 +93,27 @@
8893
"timeout": 5
8994
}
9095
]
96+
},
97+
{
98+
"matcher": "Bash|Edit|Write|Skill",
99+
"hooks": [
100+
{
101+
"type": "command",
102+
"command": "test -x \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}/zig-out/bin/ralph-hook\" && echo $CLAUDE_TOOL_INPUT | \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}/zig-out/bin/ralph-hook\" 2>/dev/null || true",
103+
"timeout": 10
104+
}
105+
]
106+
}
107+
],
108+
"PostToolUseFailure": [
109+
{
110+
"hooks": [
111+
{
112+
"type": "command",
113+
"command": "test -x \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}/zig-out/bin/ralph-hook\" && echo '{\"hook_event_name\":\"PostToolUseFailure\"}' | \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}/zig-out/bin/ralph-hook\" 2>/dev/null || true",
114+
"timeout": 10
115+
}
116+
]
91117
}
92118
]
93119
}

.mcp.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313
"railway-mcp-server": {
1414
"command": "npx",
1515
"args": ["-y", "@railway/mcp-server"]
16+
},
17+
"telegram": {
18+
"command": "npx",
19+
"args": ["-y", "@iqai/mcp-telegram"],
20+
"env": {
21+
"TELEGRAM_BOT_TOKEN": "${TELEGRAM_BOT_TOKEN}"
22+
}
1623
}
1724
}
1825
}

build.zig

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,17 @@ pub fn build(b: *std.Build) void {
13261326
const agent_step = b.step("agent", "Run Ralph autonomous agent daemon");
13271327
agent_step.dependOn(&run_agent.step);
13281328

1329+
// Ralph Hook — Tiny binary for Claude Code hooks → Telegram
1330+
const ralph_hook = b.addExecutable(.{
1331+
.name = "ralph-hook",
1332+
.root_module = b.createModule(.{
1333+
.root_source_file = b.path("tools/mcp/trinity_mcp/agent/ralph_hook.zig"),
1334+
.target = target,
1335+
.optimize = optimize,
1336+
}),
1337+
});
1338+
b.installArtifact(ralph_hook);
1339+
13291340
// ═══════════════════════════════════════════════════════════════════════════
13301341
// PHI LOOP — 999 Links of Cosmic Consciousness Gene
13311342
const phi_loop = b.addExecutable(.{

tools/mcp/trinity_mcp/agent/agent_loop.zig

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
// agent_loop.zig — Sleep-wake cycle for Ralph autonomous agent
2+
// Hooks handle per-tool Telegram reporting. This loop only sends WAKE/SLEEP.
3+
// Uses --continue for native session resume (replaces HANDOVER.md).
24
const std = @import("std");
35
const identity_mod = @import("identity.zig");
46
const handover = @import("handover.zig");
57
const github_poller = @import("github_poller.zig");
68
const context_builder = @import("context_builder.zig");
79
const claude_runner = @import("claude_runner.zig");
810
const state_mod = @import("state.zig");
11+
const telegram = @import("telegram.zig");
912

1013
pub const Config = struct {
1114
project_root: []const u8,
@@ -15,7 +18,8 @@ pub const Config = struct {
1518
sleep_interval_s: u64 = 1800, // 30 minutes
1619
max_turns: u32 = 50,
1720
max_wakes: u32 = 0, // 0 = infinite
18-
single_shot: bool = false, // true = run once and exit
21+
single_shot: bool = false,
22+
tg_config: telegram.TelegramConfig = .{ .bot_token = "", .chat_id = "", .enabled = false },
1923
};
2024

2125
fn log(comptime fmt: []const u8, args: anytype) void {
@@ -32,22 +36,28 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void {
3236
log(" repo: {s}/{s}", .{ config.owner, config.repo });
3337
log(" sleep interval: {d}s", .{config.sleep_interval_s});
3438
log(" max turns: {d}", .{config.max_turns});
39+
log(" telegram: {s}", .{if (config.tg_config.enabled) "enabled" else "disabled"});
3540

3641
while (true) {
3742
// === WAKE ===
3843
const wake_count = state.incrementWakeCount() catch 0;
3944
log("=== WAKE #{d} ===", .{wake_count});
4045

46+
// Telegram: announce wake
47+
var tg_buf: [512]u8 = undefined;
48+
telegram.sendFmt(config.tg_config, &tg_buf, "<b>ralph</b> | WAKE #{d}", .{wake_count});
49+
4150
if (config.max_wakes > 0 and wake_count > config.max_wakes) {
4251
log("Max wakes ({d}) reached. Exiting.", .{config.max_wakes});
52+
telegram.send(config.tg_config, "<b>ralph</b> | Max wakes reached. Stopping.");
4353
break;
4454
}
4555

4656
// Load identity
4757
var id = identity_mod.load(allocator, config.project_root);
4858
defer id.deinit();
4959

50-
// Read previous handover
60+
// Read previous handover (used for first wake context only)
5161
const handover_content = handover.read(allocator, config.project_root);
5262
defer if (handover_content) |h| allocator.free(h);
5363

@@ -63,11 +73,15 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void {
6373

6474
if (issues_json == null) {
6575
log("No pending issues or GitHub API unavailable. Sleeping.", .{});
76+
telegram.send(config.tg_config, "<b>ralph</b> | No issues found. Sleeping.");
6677
if (config.single_shot) break;
6778
std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s);
6879
continue;
6980
}
7081

82+
// Telegram: issues found
83+
telegram.sendFmt(config.tg_config, &tg_buf, "<b>ralph</b> | Issues found, building context...", .{});
84+
7185
// Read current state
7286
const current_issue = state.read("current_issue");
7387
defer if (current_issue) |v| allocator.free(v);
@@ -87,14 +101,25 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void {
87101

88102
log("Context built ({d} bytes). Spawning Claude CLI...", .{prompt.len});
89103

104+
// Use --continue for session resume after first wake
105+
const use_continue = wake_count > 1;
106+
107+
// Telegram: spawning Claude
108+
telegram.sendFmt(config.tg_config, &tg_buf, "<b>ralph</b> | Spawning Claude ({d}b context, {s})...", .{
109+
prompt.len,
110+
if (use_continue) "--continue" else "new session",
111+
});
112+
90113
// === WORK ===
91114
var result = claude_runner.spawn(
92115
allocator,
93116
prompt,
94117
config.project_root,
95118
config.max_turns,
119+
use_continue,
96120
) catch |err| {
97121
log("Claude spawn error: {s}", .{@errorName(err)});
122+
telegram.sendFmt(config.tg_config, &tg_buf, "<b>ralph</b> | Claude spawn FAILED: {s}", .{@errorName(err)});
98123
if (config.single_shot) break;
99124
std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s);
100125
continue;
@@ -103,33 +128,30 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void {
103128

104129
log("Claude exited with code {d} ({d} bytes output)", .{ result.exit_code, result.stdout.len });
105130

131+
// Telegram: Claude finished
132+
if (result.exit_code == 0) {
133+
telegram.sendFmt(config.tg_config, &tg_buf, "<b>ralph</b> | Claude done ({d}b output)", .{result.stdout.len});
134+
} else {
135+
telegram.sendFmt(config.tg_config, &tg_buf, "<b>ralph</b> | Claude exit={d} ({d}b output)", .{ result.exit_code, result.stdout.len });
136+
}
137+
106138
// Save session log
107139
claude_runner.saveLog(allocator, config.project_root, result.stdout);
108140

109141
// === SLEEP ===
110-
// Check if handover was written by the session
111-
const new_handover = handover.read(allocator, config.project_root);
112-
if (new_handover) |nh| {
113-
allocator.free(nh);
114-
} else {
115-
// Emergency handover — session didn't write one
116-
log("WARNING: No handover written. Creating emergency handover.", .{});
117-
handover.writeEmergency(allocator, config.project_root, wake_count, current_issue) catch {
118-
log("Failed to write emergency handover!", .{});
119-
};
120-
}
121-
122142
// Update state
123143
var count_buf: [16]u8 = undefined;
124144
const count_str = std.fmt.bufPrint(&count_buf, "{d}", .{wake_count}) catch "0";
125145
state.write("last_wake", count_str) catch {};
126146

127147
if (config.single_shot) {
128148
log("Single-shot mode. Exiting.", .{});
149+
telegram.send(config.tg_config, "<b>ralph</b> | Single-shot done. Exiting.");
129150
break;
130151
}
131152

132153
log("Sleeping for {d}s...", .{config.sleep_interval_s});
154+
telegram.sendFmt(config.tg_config, &tg_buf, "<b>ralph</b> | Sleeping {d}s...", .{config.sleep_interval_s});
133155
std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s);
134156
}
135157

tools/mcp/trinity_mcp/agent/claude_runner.zig

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// claude_runner.zig — Spawn Claude Code CLI as child process
2+
// Supports --continue for native session resume (replaces HANDOVER.md)
23
const std = @import("std");
34

45
pub const RunResult = struct {
@@ -11,37 +12,65 @@ pub const RunResult = struct {
1112
}
1213
};
1314

14-
/// Spawn `claude` CLI with the given prompt as a positional argument.
15-
/// Returns captured stdout and exit code.
15+
const allowed_tools = "Bash,Read,Write,Edit,Glob,Grep,TodoWrite,WebFetch,WebSearch,Skill,mcp__telegram__SEND_MESSAGE";
16+
17+
/// Spawn `claude` CLI. If use_continue=true, uses --continue for session resume.
18+
/// Otherwise, passes prompt via -p flag for a fresh session.
19+
/// Hooks handle per-tool Telegram reporting — no stdout parsing needed.
1620
pub fn spawn(
1721
allocator: std.mem.Allocator,
1822
prompt: []const u8,
1923
project_root: []const u8,
2024
max_turns: u32,
25+
use_continue: bool,
2126
) !RunResult {
2227
var turns_buf: [8]u8 = undefined;
2328
const turns_str = std.fmt.bufPrint(&turns_buf, "{d}", .{max_turns}) catch "50";
2429

25-
// claude --print --output-format text --max-turns N --allowedTools ... "prompt"
26-
const result = std.process.Child.run(.{
27-
.allocator = allocator,
28-
.argv = &.{
29-
"claude",
30-
"--print",
31-
"--output-format",
32-
"text",
33-
"--max-turns",
34-
turns_str,
35-
"--allowedTools",
36-
"Bash,Read,Write,Edit,Glob,Grep,TodoWrite,WebFetch,WebSearch,Skill",
37-
"-p",
38-
prompt,
39-
},
40-
.cwd = project_root,
41-
.max_output_bytes = 1024 * 1024, // 1MB
42-
}) catch |err| {
43-
const msg = try std.fmt.allocPrint(allocator, "Failed to spawn claude: {s}", .{@errorName(err)});
44-
return RunResult{ .stdout = msg, .exit_code = 1, .allocator = allocator };
30+
// Build argv based on resume mode
31+
const result = if (use_continue) blk: {
32+
break :blk std.process.Child.run(.{
33+
.allocator = allocator,
34+
.argv = &.{
35+
"claude",
36+
"--continue",
37+
"--print",
38+
"--output-format",
39+
"text",
40+
"--max-turns",
41+
turns_str,
42+
"--allowedTools",
43+
allowed_tools,
44+
"-p",
45+
prompt,
46+
},
47+
.cwd = project_root,
48+
.max_output_bytes = 1024 * 1024,
49+
}) catch |err| {
50+
const msg = try std.fmt.allocPrint(allocator, "Failed to spawn claude --continue: {s}", .{@errorName(err)});
51+
return RunResult{ .stdout = msg, .exit_code = 1, .allocator = allocator };
52+
};
53+
} else blk: {
54+
break :blk std.process.Child.run(.{
55+
.allocator = allocator,
56+
.argv = &.{
57+
"claude",
58+
"--print",
59+
"--output-format",
60+
"text",
61+
"--max-turns",
62+
turns_str,
63+
"--allowedTools",
64+
allowed_tools,
65+
"-p",
66+
prompt,
67+
},
68+
.cwd = project_root,
69+
.max_output_bytes = 1024 * 1024,
70+
}) catch |err| {
71+
const msg = try std.fmt.allocPrint(allocator, "Failed to spawn claude: {s}", .{@errorName(err)});
72+
return RunResult{ .stdout = msg, .exit_code = 1, .allocator = allocator };
73+
};
4574
};
4675

4776
// Free stderr, keep stdout

tools/mcp/trinity_mcp/agent/main.zig

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
// RALPH_MAX_TURNS — Max Claude CLI turns per session (default: 50)
1313
// RALPH_MAX_WAKES — Max wake cycles, 0=infinite (default: 0)
1414
// PROJECT_ROOT — Project root path (auto-detected if unset)
15+
// TELEGRAM_BOT_TOKEN — Telegram bot token (optional, enables TG reporting)
16+
// TELEGRAM_CHAT_ID — Telegram chat ID (optional, enables TG reporting)
1517
//
1618
const std = @import("std");
1719
const agent_loop = @import("agent_loop.zig");
20+
const telegram = @import("telegram.zig");
1821

1922
pub fn main() !void {
2023
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
@@ -38,6 +41,11 @@ pub fn main() !void {
3841
const max_turns = std.fmt.parseInt(u32, turns_s, 10) catch 50;
3942
const max_wakes = std.fmt.parseInt(u32, wakes_s, 10) catch 0;
4043

44+
// Telegram reporting (optional)
45+
const tg_token: []const u8 = std.posix.getenv("TELEGRAM_BOT_TOKEN") orelse "";
46+
const tg_chat_id: []const u8 = std.posix.getenv("TELEGRAM_CHAT_ID") orelse "";
47+
const tg_enabled = tg_token.len > 0 and tg_chat_id.len > 0;
48+
4149
// Detect project root
4250
const project_root = blk: {
4351
if (std.posix.getenv("PROJECT_ROOT")) |root| break :blk @as([]const u8, root);
@@ -69,12 +77,18 @@ pub fn main() !void {
6977
}
7078

7179
std.debug.print(
72-
\\[ralph-agent] Ralph Autonomous Agent v1.0.0
73-
\\[ralph-agent] φ² + 1/φ² = 3
80+
\\[ralph-agent] Ralph Autonomous Agent v2.0.0
81+
\\[ralph-agent] Hooks + Telegram + Session Resume
7482
\\[ralph-agent] ---
7583
\\
7684
, .{});
7785

86+
if (tg_enabled) {
87+
std.debug.print("[ralph-agent] Telegram: enabled (chat_id={s})\n", .{tg_chat_id});
88+
} else {
89+
std.debug.print("[ralph-agent] Telegram: disabled (set TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID)\n", .{});
90+
}
91+
7892
try agent_loop.run(allocator, .{
7993
.project_root = project_root,
8094
.gh_token = gh_token,
@@ -84,5 +98,10 @@ pub fn main() !void {
8498
.max_turns = max_turns,
8599
.max_wakes = max_wakes,
86100
.single_shot = single_shot,
101+
.tg_config = .{
102+
.bot_token = tg_token,
103+
.chat_id = tg_chat_id,
104+
.enabled = tg_enabled,
105+
},
87106
});
88107
}

0 commit comments

Comments
 (0)