Initial commit: Discord-Claude Gateway with event-driven agent runtime

This commit is contained in:
2026-02-22 01:30:50 -05:00
parent 28b929c58f
commit 82b0905a98
5 changed files with 159 additions and 87 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

View File

@@ -1,6 +1,7 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { writeFile, unlink } from "node:fs/promises"; import { writeFile, unlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import path from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { Event, MessagePayload, HeartbeatPayload, CronPayload, HookPayload } from "./event-queue.js"; import type { Event, MessagePayload, HeartbeatPayload, CronPayload, HookPayload } from "./event-queue.js";
@@ -18,6 +19,8 @@ export interface EventResult {
error?: string; error?: string;
} }
export type OnStreamResult = (text: string, channelId: string) => Promise<void>;
interface ClaudeJsonResponse { interface ClaudeJsonResponse {
type: string; type: string;
subtype?: string; subtype?: string;
@@ -51,9 +54,7 @@ export class AgentRuntime {
this.hookManager = hookManager; this.hookManager = hookManager;
} }
async processEvent(event: Event): Promise<EventResult> { async processEvent(event: Event, onStreamResult?: OnStreamResult): Promise<EventResult> {
// Skip inline hooks for hook events to prevent infinite recursion
// (fireInline calls processEvent which would call fireInline again)
const isHookEvent = event.type === "hook"; const isHookEvent = event.type === "hook";
if (!isHookEvent) { if (!isHookEvent) {
@@ -61,7 +62,7 @@ export class AgentRuntime {
} }
try { try {
const result = await this.processEventCore(event); const result = await this.processEventCore(event, onStreamResult);
if (!isHookEvent) { if (!isHookEvent) {
await this.hookManager.fireInline("agent_stop", this); await this.hookManager.fireInline("agent_stop", this);
} }
@@ -74,17 +75,17 @@ export class AgentRuntime {
} }
} }
private async processEventCore(event: Event): Promise<EventResult> { private async processEventCore(event: Event, onStreamResult?: OnStreamResult): Promise<EventResult> {
const configs = await this.markdownConfigLoader.loadAll(this.config.configDir); const configs = await this.markdownConfigLoader.loadAll(this.config.configDir);
const systemPrompt = this.systemPromptAssembler.assemble(configs); const systemPrompt = this.systemPromptAssembler.assemble(configs);
switch (event.type) { switch (event.type) {
case "message": case "message":
return this.processMessage(event, systemPrompt); return this.processMessage(event, systemPrompt, onStreamResult);
case "heartbeat": case "heartbeat":
return this.processHeartbeat(event, systemPrompt); return this.processHeartbeat(event, systemPrompt, onStreamResult);
case "cron": case "cron":
return this.processCron(event, systemPrompt); return this.processCron(event, systemPrompt, onStreamResult);
case "hook": case "hook":
return this.processHook(event, systemPrompt); return this.processHook(event, systemPrompt);
default: default:
@@ -92,14 +93,18 @@ export class AgentRuntime {
} }
} }
private async processMessage(event: Event, systemPrompt: string): Promise<EventResult> { private async processMessage(event: Event, systemPrompt: string, onStreamResult?: OnStreamResult): Promise<EventResult> {
const payload = event.payload as MessagePayload; const payload = event.payload as MessagePayload;
const channelId = payload.prompt.channelId; const channelId = payload.prompt.channelId;
const promptText = payload.prompt.text; const promptText = payload.prompt.text;
const existingSessionId = this.sessionManager.getSessionId(channelId); const existingSessionId = this.sessionManager.getSessionId(channelId);
const streamCallback = onStreamResult
? (text: string) => onStreamResult(text, channelId)
: undefined;
try { try {
const response = await this.executeClaude(promptText, systemPrompt, existingSessionId); const response = await this.executeClaude(promptText, systemPrompt, existingSessionId, streamCallback);
if (response.session_id && channelId) { if (response.session_id && channelId) {
this.sessionManager.setSessionId(channelId, response.session_id); this.sessionManager.setSessionId(channelId, response.session_id);
@@ -118,20 +123,28 @@ export class AgentRuntime {
} }
} }
private async processHeartbeat(event: Event, systemPrompt: string): Promise<EventResult> { private async processHeartbeat(event: Event, systemPrompt: string, onStreamResult?: OnStreamResult): Promise<EventResult> {
const payload = event.payload as HeartbeatPayload; const payload = event.payload as HeartbeatPayload;
const targetChannelId = this.config.outputChannelId;
const streamCallback = onStreamResult && targetChannelId
? (text: string) => onStreamResult(text, targetChannelId)
: undefined;
try { try {
const response = await this.executeClaude(payload.instruction, systemPrompt); const response = await this.executeClaude(payload.instruction, systemPrompt, undefined, streamCallback);
return { responseText: response.result, targetChannelId: this.config.outputChannelId }; return { responseText: response.result, targetChannelId: this.config.outputChannelId };
} catch (error) { } catch (error) {
return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId }; return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId };
} }
} }
private async processCron(event: Event, systemPrompt: string): Promise<EventResult> { private async processCron(event: Event, systemPrompt: string, onStreamResult?: OnStreamResult): Promise<EventResult> {
const payload = event.payload as CronPayload; const payload = event.payload as CronPayload;
const targetChannelId = this.config.outputChannelId;
const streamCallback = onStreamResult && targetChannelId
? (text: string) => onStreamResult(text, targetChannelId)
: undefined;
try { try {
const response = await this.executeClaude(payload.instruction, systemPrompt); const response = await this.executeClaude(payload.instruction, systemPrompt, undefined, streamCallback);
return { responseText: response.result, targetChannelId: this.config.outputChannelId }; return { responseText: response.result, targetChannelId: this.config.outputChannelId };
} catch (error) { } catch (error) {
return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId }; return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId };
@@ -153,13 +166,13 @@ export class AgentRuntime {
promptText: string, promptText: string,
systemPrompt: string, systemPrompt: string,
sessionId?: string, sessionId?: string,
onResult?: (text: string) => Promise<void>,
): Promise<ClaudeJsonResponse> { ): Promise<ClaudeJsonResponse> {
// Write system prompt to a temp file to avoid CLI arg length limits
const tmpFile = join(tmpdir(), `aetheel-prompt-${randomUUID()}.txt`); const tmpFile = join(tmpdir(), `aetheel-prompt-${randomUUID()}.txt`);
await writeFile(tmpFile, systemPrompt, "utf-8"); await writeFile(tmpFile, systemPrompt, "utf-8");
try { try {
return await this.runClaude(promptText, tmpFile, sessionId); return await this.runClaude(promptText, tmpFile, sessionId, onResult);
} finally { } finally {
unlink(tmpFile).catch(() => {}); unlink(tmpFile).catch(() => {});
} }
@@ -169,48 +182,78 @@ export class AgentRuntime {
promptText: string, promptText: string,
systemPromptFile: string, systemPromptFile: string,
sessionId?: string, sessionId?: string,
onResult?: (text: string) => Promise<void>,
): Promise<ClaudeJsonResponse> { ): Promise<ClaudeJsonResponse> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Build args — keep it minimal and match what works on the CLI directly
const args: string[] = [ const args: string[] = [
"-p", promptText, "-p", promptText,
"--output-format", "json", "--output-format", "json",
"--dangerously-skip-permissions", "--dangerously-skip-permissions",
"--append-system-prompt-file", systemPromptFile, "--append-system-prompt-file", systemPromptFile,
"--verbose",
]; ];
if (sessionId) { if (sessionId) {
args.push("--resume", sessionId); args.push("--resume", sessionId);
} }
// --allowedTools expects each tool as a separate quoted arg
for (const tool of this.config.allowedTools) { for (const tool of this.config.allowedTools) {
args.push("--allowedTools", tool); args.push("--allowedTools", tool);
} }
args.push("--max-turns", "25"); args.push("--max-turns", "25");
console.log(`[DEBUG] Spawning: ${this.config.claudeCliPath} args=${JSON.stringify(args.slice(0, 8))}... (${args.length} total)`); const configDir = path.resolve(this.config.configDir);
console.log(`[DEBUG] Spawning: ${this.config.claudeCliPath} cwd=${configDir} args=${JSON.stringify(args.slice(0, 8))}... (${args.length} total)`);
const child = spawn(this.config.claudeCliPath, args, { const child = spawn(this.config.claudeCliPath, args, {
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
cwd: configDir,
}); });
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
let parsedSessionId: string | undefined;
let lastResultText = "";
let streamedResults = false;
// Parse JSON objects from stdout as they arrive for streaming
let parseBuffer = "";
child.stdout.on("data", (data: Buffer) => { child.stdout.on("data", (data: Buffer) => {
stdout += data.toString(); const chunk = data.toString();
stdout += chunk;
parseBuffer += chunk;
// Try to parse complete JSON objects from the buffer
// The output is a JSON array like [{...},{...},...] or newline-delimited
const lines = parseBuffer.split("\n");
parseBuffer = lines.pop() || ""; // Keep incomplete last line in buffer
for (const line of lines) {
const cleaned = line.replace(/^\[/, "").replace(/,?\]$/, "").replace(/^,/, "").trim();
if (!cleaned) continue;
try {
const obj = JSON.parse(cleaned);
if (obj.type === "system" && obj.subtype === "init" && obj.session_id) {
parsedSessionId = obj.session_id;
}
if (obj.type === "result" && obj.result) {
lastResultText = obj.result;
if (onResult) {
streamedResults = true;
onResult(obj.result).catch((err) =>
console.error("[DEBUG] Stream callback error:", err)
);
}
}
} catch {
// Not valid JSON yet
}
}
}); });
child.stderr.on("data", (data: Buffer) => { child.stderr.on("data", (data: Buffer) => {
const chunk = data.toString(); stderr += data.toString();
stderr += chunk;
// Log stderr in real-time so we can see what's happening
if (chunk.trim()) {
console.log(`[DEBUG] Claude stderr: ${chunk.trim().slice(0, 200)}`);
}
}); });
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -221,76 +264,52 @@ export class AgentRuntime {
child.on("close", (code) => { child.on("close", (code) => {
clearTimeout(timer); clearTimeout(timer);
console.log(`[DEBUG] Claude CLI exited: code=${code}, stdout=${stdout.length} chars, streamed=${streamedResults}`);
console.log(`[DEBUG] Claude CLI exited: code=${code}, stdout=${stdout.length} chars`);
if (stdout.length > 0) {
console.log(`[DEBUG] Claude stdout preview: ${stdout.slice(0, 300)}`);
}
if (code !== 0 && code !== null) { if (code !== 0 && code !== null) {
reject(new Error(`Claude CLI error (exit ${code}): ${stderr.slice(0, 500) || "unknown error"}`)); reject(new Error(`Claude CLI error (exit ${code}): ${stderr.slice(0, 500) || "unknown error"}`));
return; return;
} }
try { // Final parse of any remaining buffer
// The CLI with --output-format json returns newline-delimited JSON objects. if (parseBuffer.trim()) {
// We need to find the "result" type message which contains the actual response. try {
const lines = stdout.trim().split("\n"); const cleaned = parseBuffer.replace(/^\[/, "").replace(/,?\]$/, "").replace(/^,/, "").trim();
let resultText = ""; const obj = JSON.parse(cleaned);
let sessionId: string | undefined; if (obj.type === "system" && obj.subtype === "init" && obj.session_id) {
parsedSessionId = obj.session_id;
for (const line of lines) {
try {
// Each line might be a JSON object or part of a JSON array
const cleaned = line.replace(/^\[/, "").replace(/,?\]$/, "").replace(/^,/, "").trim();
if (!cleaned) continue;
const obj = JSON.parse(cleaned);
if (obj.type === "system" && obj.subtype === "init" && obj.session_id) {
sessionId = obj.session_id;
}
if (obj.type === "result" && obj.result) {
resultText = obj.result;
}
} catch {
// Skip unparseable lines
} }
} if (obj.type === "result" && obj.result) {
lastResultText = obj.result;
}
} catch { /* ignore */ }
}
// If we couldn't parse individual lines, try parsing the whole thing as a JSON array // If we didn't get results from line-by-line parsing, try the full output
if (!resultText) { if (!lastResultText) {
try { try {
const arr = JSON.parse(stdout.trim()); const arr = JSON.parse(stdout.trim());
if (Array.isArray(arr)) { if (Array.isArray(arr)) {
for (const obj of arr) { for (const obj of arr) {
if (obj.type === "system" && obj.subtype === "init" && obj.session_id) { if (obj.type === "system" && obj.subtype === "init" && obj.session_id) {
sessionId = obj.session_id; parsedSessionId = obj.session_id;
} }
if (obj.type === "result" && obj.result) { if (obj.type === "result" && obj.result) {
resultText = obj.result; lastResultText = obj.result;
}
} }
} }
} catch {
// Not a JSON array either
} }
} } catch { /* ignore */ }
console.log(`[DEBUG] Parsed result: ${resultText.length} chars, session=${sessionId ?? "none"}`);
resolve({
type: "result",
result: resultText || undefined,
session_id: sessionId,
is_error: false,
});
} catch {
resolve({
type: "result",
result: stdout.trim(),
});
} }
console.log(`[DEBUG] Parsed: result=${lastResultText.length} chars, session=${parsedSessionId ?? "none"}`);
resolve({
type: "result",
result: streamedResults ? undefined : lastResultText || undefined,
session_id: parsedSessionId,
is_error: false,
});
}); });
child.on("error", (err) => { child.on("error", (err) => {

View File

@@ -1,3 +1,4 @@
import { join } from "node:path";
import { loadConfig, type GatewayConfig } from "./config.js"; import { loadConfig, type GatewayConfig } from "./config.js";
import { DiscordBot, type Prompt } from "./discord-bot.js"; import { DiscordBot, type Prompt } from "./discord-bot.js";
import { EventQueue, type Event, type MessagePayload } from "./event-queue.js"; import { EventQueue, type Event, type MessagePayload } from "./event-queue.js";
@@ -44,7 +45,7 @@ export class GatewayCore {
this.eventQueue = new EventQueue(this.config.maxQueueDepth); this.eventQueue = new EventQueue(this.config.maxQueueDepth);
// 5. Initialize AgentRuntime with all dependencies // 5. Initialize AgentRuntime with all dependencies
this.sessionManager = new SessionManager(); this.sessionManager = new SessionManager(join(this.config.configDir, "sessions.json"));
this.markdownConfigLoader = new MarkdownConfigLoader(); this.markdownConfigLoader = new MarkdownConfigLoader();
const systemPromptAssembler = new SystemPromptAssembler(); const systemPromptAssembler = new SystemPromptAssembler();
this.hookManager = new HookManager(); this.hookManager = new HookManager();
@@ -94,9 +95,18 @@ export class GatewayCore {
this.eventQueue.onEvent(async (event: Event) => { this.eventQueue.onEvent(async (event: Event) => {
console.log(`[DEBUG] Processing event: type=${event.type}, id=${event.id}`); console.log(`[DEBUG] Processing event: type=${event.type}, id=${event.id}`);
try { try {
const result = await this.agentRuntime.processEvent(event); // Streaming callback — sends results to Discord as they arrive
const onStreamResult = async (text: string, channelId: string) => {
const chunks = splitMessage(text);
for (const chunk of chunks) {
await this.discordBot.sendMessage(channelId, chunk);
}
};
const result = await this.agentRuntime.processEvent(event, onStreamResult);
console.log(`[DEBUG] Event result: responseText=${result.responseText?.length ?? 0} chars, error=${result.error ?? "none"}`); console.log(`[DEBUG] Event result: responseText=${result.responseText?.length ?? 0} chars, error=${result.error ?? "none"}`);
// Only send if not already streamed
if (result.responseText && result.targetChannelId) { if (result.responseText && result.targetChannelId) {
const chunks = splitMessage(result.responseText); const chunks = splitMessage(result.responseText);
for (const chunk of chunks) { for (const chunk of chunks) {

View File

@@ -1,5 +1,16 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
export class SessionManager { export class SessionManager {
private bindings = new Map<string, string>(); private bindings = new Map<string, string>();
private persistPath: string | null = null;
constructor(persistPath?: string) {
if (persistPath) {
this.persistPath = persistPath;
this.loadFromDisk();
}
}
getSessionId(channelId: string): string | undefined { getSessionId(channelId: string): string | undefined {
return this.bindings.get(channelId); return this.bindings.get(channelId);
@@ -7,13 +18,44 @@ export class SessionManager {
setSessionId(channelId: string, sessionId: string): void { setSessionId(channelId: string, sessionId: string): void {
this.bindings.set(channelId, sessionId); this.bindings.set(channelId, sessionId);
this.saveToDisk();
} }
removeSession(channelId: string): void { removeSession(channelId: string): void {
this.bindings.delete(channelId); this.bindings.delete(channelId);
this.saveToDisk();
} }
clear(): void { clear(): void {
this.bindings.clear(); this.bindings.clear();
this.saveToDisk();
}
private loadFromDisk(): void {
if (!this.persistPath) return;
try {
const data = readFileSync(this.persistPath, "utf-8");
const parsed = JSON.parse(data) as Record<string, string>;
for (const [k, v] of Object.entries(parsed)) {
this.bindings.set(k, v);
}
console.log(`Sessions loaded: ${this.bindings.size} channel(s)`);
} catch {
// File doesn't exist yet — that's fine
}
}
private saveToDisk(): void {
if (!this.persistPath) return;
try {
mkdirSync(dirname(this.persistPath), { recursive: true });
const obj: Record<string, string> = {};
for (const [k, v] of this.bindings) {
obj[k] = v;
}
writeFileSync(this.persistPath, JSON.stringify(obj, null, 2), "utf-8");
} catch (err) {
console.error("Failed to persist sessions:", err);
}
} }
} }

View File

@@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
globals: true, globals: true,
exclude: ["**/node_modules/**", "**/references/**", "**/dist/**"],
}, },
}); });