Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .claude-plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
Expand Down Expand Up @@ -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
}
]
}
]
}
Expand Down
7 changes: 7 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}
}
}
}
11 changes: 11 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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(.{
Expand Down
50 changes: 36 additions & 14 deletions tools/mcp/trinity_mcp/agent/agent_loop.zig
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// 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");
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,
Expand All @@ -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 {
Expand All @@ -32,22 +36,28 @@ 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, "<b>ralph</b> | 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, "<b>ralph</b> | Max wakes reached. Stopping.");
break;
}

// Load identity
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);

Expand All @@ -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, "<b>ralph</b> | 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, "<b>ralph</b> | Issues found, building context...", .{});

// Read current state
const current_issue = state.read("current_issue");
defer if (current_issue) |v| allocator.free(v);
Expand All @@ -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, "<b>ralph</b> | 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, "<b>ralph</b> | Claude spawn FAILED: {s}", .{@errorName(err)});
if (config.single_shot) break;
std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s);
continue;
Expand All @@ -103,33 +128,30 @@ 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, "<b>ralph</b> | Claude done ({d}b output)", .{result.stdout.len});
} else {
telegram.sendFmt(config.tg_config, &tg_buf, "<b>ralph</b> | 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";
state.write("last_wake", count_str) catch {};

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

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

Expand Down
73 changes: 51 additions & 22 deletions tools/mcp/trinity_mcp/agent/claude_runner.zig
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down
23 changes: 21 additions & 2 deletions tools/mcp/trinity_mcp/agent/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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(.{}){};
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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,
},
});
}
Loading
Loading