diff --git a/.claude-plugin/hooks/hooks.json b/.claude-plugin/hooks/hooks.json index fb8a888e5d..baf9692115 100644 --- a/.claude-plugin/hooks/hooks.json +++ b/.claude-plugin/hooks/hooks.json @@ -12,6 +12,11 @@ "type": "command", "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", "timeout": 5 + }, + { + "type": "command", + "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", + "timeout": 10 } ] } @@ -88,6 +93,27 @@ "timeout": 5 } ] + }, + { + "matcher": "Bash|Edit|Write|Skill", + "hooks": [ + { + "type": "command", + "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", + "timeout": 10 + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "command", + "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", + "timeout": 10 + } + ] } ] } diff --git a/.mcp.json b/.mcp.json index 73818b06d4..2d054ca60d 100644 --- a/.mcp.json +++ b/.mcp.json @@ -13,6 +13,13 @@ "railway-mcp-server": { "command": "npx", "args": ["-y", "@railway/mcp-server"] + }, + "telegram": { + "command": "npx", + "args": ["-y", "@iqai/mcp-telegram"], + "env": { + "TELEGRAM_BOT_TOKEN": "${TELEGRAM_BOT_TOKEN}" + } } } } diff --git a/build.zig b/build.zig index 52cab9f273..baf71479ba 100644 --- a/build.zig +++ b/build.zig @@ -1326,6 +1326,17 @@ pub fn build(b: *std.Build) void { const agent_step = b.step("agent", "Run Ralph autonomous agent daemon"); agent_step.dependOn(&run_agent.step); + // Ralph Hook — Tiny binary for Claude Code hooks → Telegram + const ralph_hook = b.addExecutable(.{ + .name = "ralph-hook", + .root_module = b.createModule(.{ + .root_source_file = b.path("tools/mcp/trinity_mcp/agent/ralph_hook.zig"), + .target = target, + .optimize = optimize, + }), + }); + b.installArtifact(ralph_hook); + // ═══════════════════════════════════════════════════════════════════════════ // PHI LOOP — 999 Links of Cosmic Consciousness Gene const phi_loop = b.addExecutable(.{ diff --git a/tools/mcp/trinity_mcp/agent/agent_loop.zig b/tools/mcp/trinity_mcp/agent/agent_loop.zig index 9956a3b84f..b22e1cccbd 100644 --- a/tools/mcp/trinity_mcp/agent/agent_loop.zig +++ b/tools/mcp/trinity_mcp/agent/agent_loop.zig @@ -1,4 +1,6 @@ // agent_loop.zig — Sleep-wake cycle for Ralph autonomous agent +// Hooks handle per-tool Telegram reporting. This loop only sends WAKE/SLEEP. +// Uses --continue for native session resume (replaces HANDOVER.md). const std = @import("std"); const identity_mod = @import("identity.zig"); const handover = @import("handover.zig"); @@ -6,6 +8,7 @@ const github_poller = @import("github_poller.zig"); const context_builder = @import("context_builder.zig"); const claude_runner = @import("claude_runner.zig"); const state_mod = @import("state.zig"); +const telegram = @import("telegram.zig"); pub const Config = struct { project_root: []const u8, @@ -15,7 +18,8 @@ pub const Config = struct { sleep_interval_s: u64 = 1800, // 30 minutes max_turns: u32 = 50, max_wakes: u32 = 0, // 0 = infinite - single_shot: bool = false, // true = run once and exit + single_shot: bool = false, + tg_config: telegram.TelegramConfig = .{ .bot_token = "", .chat_id = "", .enabled = false }, }; fn log(comptime fmt: []const u8, args: anytype) void { @@ -32,14 +36,20 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void { log(" repo: {s}/{s}", .{ config.owner, config.repo }); log(" sleep interval: {d}s", .{config.sleep_interval_s}); log(" max turns: {d}", .{config.max_turns}); + log(" telegram: {s}", .{if (config.tg_config.enabled) "enabled" else "disabled"}); while (true) { // === WAKE === const wake_count = state.incrementWakeCount() catch 0; log("=== WAKE #{d} ===", .{wake_count}); + // Telegram: announce wake + var tg_buf: [512]u8 = undefined; + telegram.sendFmt(config.tg_config, &tg_buf, "ralph | WAKE #{d}", .{wake_count}); + if (config.max_wakes > 0 and wake_count > config.max_wakes) { log("Max wakes ({d}) reached. Exiting.", .{config.max_wakes}); + telegram.send(config.tg_config, "ralph | Max wakes reached. Stopping."); break; } @@ -47,7 +57,7 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void { var id = identity_mod.load(allocator, config.project_root); defer id.deinit(); - // Read previous handover + // Read previous handover (used for first wake context only) const handover_content = handover.read(allocator, config.project_root); defer if (handover_content) |h| allocator.free(h); @@ -63,11 +73,15 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void { if (issues_json == null) { log("No pending issues or GitHub API unavailable. Sleeping.", .{}); + telegram.send(config.tg_config, "ralph | No issues found. Sleeping."); if (config.single_shot) break; std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s); continue; } + // Telegram: issues found + telegram.sendFmt(config.tg_config, &tg_buf, "ralph | Issues found, building context...", .{}); + // Read current state const current_issue = state.read("current_issue"); defer if (current_issue) |v| allocator.free(v); @@ -87,14 +101,25 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void { log("Context built ({d} bytes). Spawning Claude CLI...", .{prompt.len}); + // Use --continue for session resume after first wake + const use_continue = wake_count > 1; + + // Telegram: spawning Claude + telegram.sendFmt(config.tg_config, &tg_buf, "ralph | Spawning Claude ({d}b context, {s})...", .{ + prompt.len, + if (use_continue) "--continue" else "new session", + }); + // === WORK === var result = claude_runner.spawn( allocator, prompt, config.project_root, config.max_turns, + use_continue, ) catch |err| { log("Claude spawn error: {s}", .{@errorName(err)}); + telegram.sendFmt(config.tg_config, &tg_buf, "ralph | Claude spawn FAILED: {s}", .{@errorName(err)}); if (config.single_shot) break; std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s); continue; @@ -103,22 +128,17 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void { log("Claude exited with code {d} ({d} bytes output)", .{ result.exit_code, result.stdout.len }); + // Telegram: Claude finished + if (result.exit_code == 0) { + telegram.sendFmt(config.tg_config, &tg_buf, "ralph | Claude done ({d}b output)", .{result.stdout.len}); + } else { + telegram.sendFmt(config.tg_config, &tg_buf, "ralph | Claude exit={d} ({d}b output)", .{ result.exit_code, result.stdout.len }); + } + // Save session log claude_runner.saveLog(allocator, config.project_root, result.stdout); // === SLEEP === - // Check if handover was written by the session - const new_handover = handover.read(allocator, config.project_root); - if (new_handover) |nh| { - allocator.free(nh); - } else { - // Emergency handover — session didn't write one - log("WARNING: No handover written. Creating emergency handover.", .{}); - handover.writeEmergency(allocator, config.project_root, wake_count, current_issue) catch { - log("Failed to write emergency handover!", .{}); - }; - } - // Update state var count_buf: [16]u8 = undefined; const count_str = std.fmt.bufPrint(&count_buf, "{d}", .{wake_count}) catch "0"; @@ -126,10 +146,12 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void { if (config.single_shot) { log("Single-shot mode. Exiting.", .{}); + telegram.send(config.tg_config, "ralph | Single-shot done. Exiting."); break; } log("Sleeping for {d}s...", .{config.sleep_interval_s}); + telegram.sendFmt(config.tg_config, &tg_buf, "ralph | Sleeping {d}s...", .{config.sleep_interval_s}); std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s); } diff --git a/tools/mcp/trinity_mcp/agent/claude_runner.zig b/tools/mcp/trinity_mcp/agent/claude_runner.zig index f8fb712984..c83fb614af 100644 --- a/tools/mcp/trinity_mcp/agent/claude_runner.zig +++ b/tools/mcp/trinity_mcp/agent/claude_runner.zig @@ -1,4 +1,5 @@ // claude_runner.zig — Spawn Claude Code CLI as child process +// Supports --continue for native session resume (replaces HANDOVER.md) const std = @import("std"); pub const RunResult = struct { @@ -11,37 +12,65 @@ pub const RunResult = struct { } }; -/// Spawn `claude` CLI with the given prompt as a positional argument. -/// Returns captured stdout and exit code. +const allowed_tools = "Bash,Read,Write,Edit,Glob,Grep,TodoWrite,WebFetch,WebSearch,Skill,mcp__telegram__SEND_MESSAGE"; + +/// Spawn `claude` CLI. If use_continue=true, uses --continue for session resume. +/// Otherwise, passes prompt via -p flag for a fresh session. +/// Hooks handle per-tool Telegram reporting — no stdout parsing needed. pub fn spawn( allocator: std.mem.Allocator, prompt: []const u8, project_root: []const u8, max_turns: u32, + use_continue: bool, ) !RunResult { var turns_buf: [8]u8 = undefined; const turns_str = std.fmt.bufPrint(&turns_buf, "{d}", .{max_turns}) catch "50"; - // claude --print --output-format text --max-turns N --allowedTools ... "prompt" - const result = std.process.Child.run(.{ - .allocator = allocator, - .argv = &.{ - "claude", - "--print", - "--output-format", - "text", - "--max-turns", - turns_str, - "--allowedTools", - "Bash,Read,Write,Edit,Glob,Grep,TodoWrite,WebFetch,WebSearch,Skill", - "-p", - prompt, - }, - .cwd = project_root, - .max_output_bytes = 1024 * 1024, // 1MB - }) catch |err| { - const msg = try std.fmt.allocPrint(allocator, "Failed to spawn claude: {s}", .{@errorName(err)}); - return RunResult{ .stdout = msg, .exit_code = 1, .allocator = allocator }; + // Build argv based on resume mode + const result = if (use_continue) blk: { + break :blk std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ + "claude", + "--continue", + "--print", + "--output-format", + "text", + "--max-turns", + turns_str, + "--allowedTools", + allowed_tools, + "-p", + prompt, + }, + .cwd = project_root, + .max_output_bytes = 1024 * 1024, + }) catch |err| { + const msg = try std.fmt.allocPrint(allocator, "Failed to spawn claude --continue: {s}", .{@errorName(err)}); + return RunResult{ .stdout = msg, .exit_code = 1, .allocator = allocator }; + }; + } else blk: { + break :blk std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ + "claude", + "--print", + "--output-format", + "text", + "--max-turns", + turns_str, + "--allowedTools", + allowed_tools, + "-p", + prompt, + }, + .cwd = project_root, + .max_output_bytes = 1024 * 1024, + }) catch |err| { + const msg = try std.fmt.allocPrint(allocator, "Failed to spawn claude: {s}", .{@errorName(err)}); + return RunResult{ .stdout = msg, .exit_code = 1, .allocator = allocator }; + }; }; // Free stderr, keep stdout diff --git a/tools/mcp/trinity_mcp/agent/main.zig b/tools/mcp/trinity_mcp/agent/main.zig index 4ccec6db78..69abb1ddb8 100644 --- a/tools/mcp/trinity_mcp/agent/main.zig +++ b/tools/mcp/trinity_mcp/agent/main.zig @@ -12,9 +12,12 @@ // RALPH_MAX_TURNS — Max Claude CLI turns per session (default: 50) // RALPH_MAX_WAKES — Max wake cycles, 0=infinite (default: 0) // PROJECT_ROOT — Project root path (auto-detected if unset) +// TELEGRAM_BOT_TOKEN — Telegram bot token (optional, enables TG reporting) +// TELEGRAM_CHAT_ID — Telegram chat ID (optional, enables TG reporting) // const std = @import("std"); const agent_loop = @import("agent_loop.zig"); +const telegram = @import("telegram.zig"); pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; @@ -38,6 +41,11 @@ pub fn main() !void { const max_turns = std.fmt.parseInt(u32, turns_s, 10) catch 50; const max_wakes = std.fmt.parseInt(u32, wakes_s, 10) catch 0; + // Telegram reporting (optional) + const tg_token: []const u8 = std.posix.getenv("TELEGRAM_BOT_TOKEN") orelse ""; + const tg_chat_id: []const u8 = std.posix.getenv("TELEGRAM_CHAT_ID") orelse ""; + const tg_enabled = tg_token.len > 0 and tg_chat_id.len > 0; + // Detect project root const project_root = blk: { if (std.posix.getenv("PROJECT_ROOT")) |root| break :blk @as([]const u8, root); @@ -69,12 +77,18 @@ pub fn main() !void { } std.debug.print( - \\[ralph-agent] Ralph Autonomous Agent v1.0.0 - \\[ralph-agent] φ² + 1/φ² = 3 + \\[ralph-agent] Ralph Autonomous Agent v2.0.0 + \\[ralph-agent] Hooks + Telegram + Session Resume \\[ralph-agent] --- \\ , .{}); + if (tg_enabled) { + std.debug.print("[ralph-agent] Telegram: enabled (chat_id={s})\n", .{tg_chat_id}); + } else { + std.debug.print("[ralph-agent] Telegram: disabled (set TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID)\n", .{}); + } + try agent_loop.run(allocator, .{ .project_root = project_root, .gh_token = gh_token, @@ -84,5 +98,10 @@ pub fn main() !void { .max_turns = max_turns, .max_wakes = max_wakes, .single_shot = single_shot, + .tg_config = .{ + .bot_token = tg_token, + .chat_id = tg_chat_id, + .enabled = tg_enabled, + }, }); } diff --git a/tools/mcp/trinity_mcp/agent/ralph_hook.zig b/tools/mcp/trinity_mcp/agent/ralph_hook.zig new file mode 100644 index 0000000000..6571a8993c --- /dev/null +++ b/tools/mcp/trinity_mcp/agent/ralph_hook.zig @@ -0,0 +1,92 @@ +// ralph_hook.zig — Tiny binary for Claude Code hooks → Telegram +// +// Called by Claude Code hooks (type: "command"): +// Reads JSON from stdin: {hook_event_name, tool_name, tool_input, tool_output} +// Formats and sends to Telegram via sendMessage +// +// Usage in .claude-plugin/hooks/hooks.json: +// "command": "$CLAUDE_PROJECT_DIR/zig-out/bin/ralph-hook" +// +// Env vars: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID +const std = @import("std"); +const telegram = @import("telegram.zig"); + +pub fn main() !void { + // Read Telegram config from env + const bot_token: []const u8 = std.posix.getenv("TELEGRAM_BOT_TOKEN") orelse return; + const chat_id: []const u8 = std.posix.getenv("TELEGRAM_CHAT_ID") orelse return; + const config = telegram.TelegramConfig{ + .bot_token = bot_token, + .chat_id = chat_id, + .enabled = true, + }; + + // Read hook JSON from stdin via posix.read + var input_buf: [65536]u8 = undefined; + var total: usize = 0; + while (total < input_buf.len) { + const n = std.posix.read(0, input_buf[total..]) catch break; + if (n == 0) break; + total += n; + } + if (total == 0) return; + const input = input_buf[0..total]; + + // Extract fields via simple string search + const event = extractJsonString(input, "hook_event_name") orelse "unknown"; + const tool = extractJsonString(input, "tool_name") orelse ""; + + // Format message based on event type + var buf: [512]u8 = undefined; + const msg = if (std.mem.eql(u8, event, "PostToolUse")) + std.fmt.bufPrint(&buf, "ralph | {s} done", .{truncate(tool, 40)}) catch return + else if (std.mem.eql(u8, event, "PostToolUseFailure")) + std.fmt.bufPrint(&buf, "ralph | {s} FAILED", .{truncate(tool, 40)}) catch return + else if (std.mem.eql(u8, event, "PreToolUse")) + std.fmt.bufPrint(&buf, "ralph | {s}...", .{truncate(tool, 40)}) catch return + else if (std.mem.eql(u8, event, "Stop")) + std.fmt.bufPrint(&buf, "ralph | Session finished", .{}) catch return + else if (std.mem.eql(u8, event, "SessionStart")) + std.fmt.bufPrint(&buf, "ralph | Session started", .{}) catch return + else + return; // Unknown event, skip + + telegram.send(config, msg); +} + +/// Extract a JSON string value by key using simple pattern matching. +/// Looks for "key":"value" in the input. +fn extractJsonString(json: []const u8, key: []const u8) ?[]const u8 { + // Build needle: "key":" + var needle_buf: [128]u8 = undefined; + const needle = std.fmt.bufPrint(&needle_buf, "\"{s}\":\"", .{key}) catch return null; + + const idx = std.mem.indexOf(u8, json, needle) orelse return null; + const start = idx + needle.len; + if (start >= json.len) return null; + + // Find closing quote (skip escaped quotes) + var end = start; + while (end < json.len) : (end += 1) { + if (json[end] == '"' and (end == start or json[end - 1] != '\\')) break; + } + if (end == start) return null; + return json[start..end]; +} + +fn truncate(s: []const u8, max: usize) []const u8 { + return if (s.len <= max) s else s[0..max]; +} + +test "extractJsonString basic" { + const json = "{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Bash\"}"; + const event = extractJsonString(json, "hook_event_name") orelse return error.NotFound; + try std.testing.expectEqualStrings("PostToolUse", event); + const tool = extractJsonString(json, "tool_name") orelse return error.NotFound; + try std.testing.expectEqualStrings("Bash", tool); +} + +test "extractJsonString missing key" { + const json = "{\"hook_event_name\":\"Stop\"}"; + try std.testing.expect(extractJsonString(json, "tool_name") == null); +} diff --git a/tools/mcp/trinity_mcp/agent/telegram.zig b/tools/mcp/trinity_mcp/agent/telegram.zig new file mode 100644 index 0000000000..0c85db42a6 --- /dev/null +++ b/tools/mcp/trinity_mcp/agent/telegram.zig @@ -0,0 +1,120 @@ +// telegram.zig — Fire-and-forget Telegram sender for ralph-agent +// Uses sendMessage + sendMessageDraft (Bot API 9.5 streaming) +// Pattern from oracle_watchdog.zig:515-598 +const std = @import("std"); + +pub const TelegramConfig = struct { + bot_token: []const u8, + chat_id: []const u8, + enabled: bool, +}; + +/// Send a final message via Telegram Bot API sendMessage. +/// Fire-and-forget: errors logged to stderr, never propagated. +pub fn send(config: TelegramConfig, text: []const u8) void { + if (!config.enabled) return; + sendToEndpoint(config, "sendMessage", text); +} + +/// Send a streaming draft via Telegram Bot API 9.5 sendMessageDraft. +/// Repeated calls with growing text = live streaming in chat. +pub fn sendDraft(config: TelegramConfig, text: []const u8) void { + if (!config.enabled) return; + sendToEndpoint(config, "sendMessageDraft", text); +} + +/// Format a message and send it. +pub fn sendFmt(config: TelegramConfig, buf: []u8, comptime fmt: []const u8, args: anytype) void { + if (!config.enabled) return; + const msg = std.fmt.bufPrint(buf, fmt, args) catch return; + send(config, msg); +} + +fn sendToEndpoint(config: TelegramConfig, endpoint: []const u8, text: []const u8) void { + // Build URL: https://api.telegram.org/bot{token}/{endpoint} + var url_buf: [512]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "https://api.telegram.org/bot{s}/{s}", .{ config.bot_token, endpoint }) catch return; + + // Build JSON body with manual escaping + var body_buf: [4096]u8 = undefined; + var i: usize = 0; + + const prefix = "{\"chat_id\":\""; + @memcpy(body_buf[i..][0..prefix.len], prefix); + i += prefix.len; + @memcpy(body_buf[i..][0..config.chat_id.len], config.chat_id); + i += config.chat_id.len; + + const mid = "\",\"text\":\""; + @memcpy(body_buf[i..][0..mid.len], mid); + i += mid.len; + + // JSON-escape the text + for (text) |c| { + if (i + 2 >= body_buf.len - 30) break; // reserve space for suffix + switch (c) { + '"' => { + body_buf[i] = '\\'; + body_buf[i + 1] = '"'; + i += 2; + }, + '\\' => { + body_buf[i] = '\\'; + body_buf[i + 1] = '\\'; + i += 2; + }, + '\n' => { + body_buf[i] = '\\'; + body_buf[i + 1] = 'n'; + i += 2; + }, + '\r' => { + body_buf[i] = '\\'; + body_buf[i + 1] = 'r'; + i += 2; + }, + else => { + body_buf[i] = c; + i += 1; + }, + } + } + + const suffix = "\",\"parse_mode\":\"HTML\"}"; + if (i + suffix.len <= body_buf.len) { + @memcpy(body_buf[i..][0..suffix.len], suffix); + i += suffix.len; + } + + const body = body_buf[0..i]; + + // Fire-and-forget HTTP POST (internal GPA per-call) + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + const result = client.fetch(.{ + .location = .{ .url = url }, + .method = .POST, + .payload = body, + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "application/json" }, + }, + }) catch |err| { + std.debug.print("[telegram] send error: {s}\n", .{@errorName(err)}); + return; + }; + + if (result.status != .ok) { + std.debug.print("[telegram] API returned status {d}\n", .{@intFromEnum(result.status)}); + } +} + +test "TelegramConfig disabled does not crash" { + const config = TelegramConfig{ .bot_token = "", .chat_id = "", .enabled = false }; + send(config, "test"); + sendDraft(config, "test"); +}