Skip to content
Open
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
37 changes: 35 additions & 2 deletions src/commands/telegram.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ensureProjectClaudeMd, run, runUserMessage } from "../runner";
import { ensureProjectClaudeMd, run, runUserMessage, runFork, killActive, isMainBusy } from "../runner";
import { getSettings, loadSettings } from "../config";
import { resetSession } from "../sessions";
import { transcribeAudioToText } from "../whisper";
Expand Down Expand Up @@ -516,6 +516,36 @@ async function handleMessage(message: TelegramMessage): Promise<void> {
return;
}

if (command === "/kill") {
const killed = killActive();
await sendMessage(config.token, chatId, killed ? "Killed active agent." : "No active agent running.", threadId);
return;
}

if (command === "/fork") {
const forkPrompt = text.replace(/^\/fork\s*/i, "").trim();
if (!forkPrompt) {
await sendMessage(config.token, chatId, "Usage: /fork <prompt>", threadId);
return;
}
const typingInterval = setInterval(() => sendTyping(config.token, chatId, threadId), 4000);
try {
await sendTyping(config.token, chatId, threadId);
const senderLabel = message.from?.username ?? String(userId ?? "unknown");
const result = await runFork(`[Telegram from ${senderLabel}]\nMessage: ${forkPrompt}`);
if (result.exitCode !== 0) {
await sendMessage(config.token, chatId, `Fork error (exit ${result.exitCode}): ${result.stderr || "Unknown error"}`, threadId);
} else {
await sendMessage(config.token, chatId, result.stdout || "(empty response)", threadId);
}
} catch (err) {
await sendMessage(config.token, chatId, `Fork error: ${err instanceof Error ? err.message : String(err)}`, threadId);
} finally {
clearInterval(typingInterval);
}
return;
}

// Secretary: detect reply to a bot alert message → treat as custom reply
const replyToMsgId = message.reply_to_message?.message_id;
if (replyToMsgId && text && botId && message.reply_to_message?.from?.id === botId) {
Expand Down Expand Up @@ -598,7 +628,10 @@ async function handleMessage(message: TelegramMessage): Promise<void> {
);
}
const prefixedPrompt = promptParts.join("\n");
const result = await runUserMessage("telegram", prefixedPrompt);
const busy = isMainBusy();
const result = busy
? await runFork(prefixedPrompt)
: await runUserMessage("telegram", prefixedPrompt);

if (result.exitCode !== 0) {
await sendMessage(config.token, chatId, `Error (exit ${result.exitCode}): ${result.stderr || "Unknown error"}`, threadId);
Expand Down
89 changes: 89 additions & 0 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@ function enqueue<T>(fn: () => Promise<T>): Promise<T> {
return task;
}

// Active process tracking — allows kill from outside
let activeProc: ReturnType<typeof Bun.spawn> | null = null;

/** Kill the currently running claude subprocess. Returns true if something was killed. */
export function killActive(): boolean {
if (!activeProc) return false;
try { activeProc.kill(); } catch {}
activeProc = null;
return true;
}

// Tracks whether the main serial queue is currently executing
let mainRunning = false;

/** True while the main agent is processing a task (excludes fork). */
export function isMainBusy(): boolean {
return mainRunning;
}

function extractRateLimitMessage(stdout: string, stderr: string): string | null {
const candidates = [stdout, stderr];
for (const text of candidates) {
Expand Down Expand Up @@ -88,11 +107,13 @@ async function runClaudeOnce(
env: buildChildEnv(baseEnv, model, api),
});

activeProc = proc;
const [rawStdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
if (activeProc === proc) activeProc = null;

return {
rawStdout,
Expand Down Expand Up @@ -224,6 +245,8 @@ export async function loadHeartbeatPromptTemplate(): Promise<string> {
}

async function execClaude(name: string, prompt: string): Promise<RunResult> {
mainRunning = true;
try {
await mkdir(LOGS_DIR, { recursive: true });

const existing = await getSession();
Expand Down Expand Up @@ -340,6 +363,9 @@ async function execClaude(name: string, prompt: string): Promise<RunResult> {
console.log(`[${new Date().toLocaleTimeString()}] Done: ${name} → ${logFile}`);

return result;
} finally {
mainRunning = false;
}
}

export async function run(name: string, prompt: string): Promise<RunResult> {
Expand All @@ -361,6 +387,69 @@ export async function runUserMessage(name: string, prompt: string): Promise<RunR
return run(name, prefixUserMessageWithClock(prompt));
}

// Path where Claude Code stores session JSONL transcripts for this project
const CLAUDE_SESSIONS_DIR = join(
process.env.HOME ?? "/root",
".claude",
"projects",
PROJECT_DIR.replace(/\//g, "-")
);

const FORK_SYSTEM_PROMPT = [
"You are a FORK AGENT — a fast, lightweight watcher running in parallel with the main agent.",
"",
"SPEED IS YOUR PRIORITY. Be brief. Answer in 1-3 sentences. No preamble, no padding.",
"Do NOT over-analyze. Do NOT think through edge cases. Just answer and stop.",
"",
"Your job: answer quick questions and peek at the main agent's progress via its session transcript.",
"",
"DENY immediately (one sentence explanation) any request that would take more than ~30 seconds:",
"• Compiling / building anything (kernels, projects, binaries)",
"• Downloads or network fetches",
"• Fuzzing, long analysis, heavy computations",
"• Anything that would block you and prevent monitoring/killing the main agent",
"",
"ALLOW:",
"• Reading files (especially JSONL transcripts to report main agent progress)",
"• Short factual answers",
"• Reporting on what the main agent is currently doing",
"",
`Main session info lives at: /project/.claude/claudeclaw/session.json`,
`Session JSONL transcripts dir: ${CLAUDE_SESSIONS_DIR}`,
"To peek at main agent progress: read session.json for the session ID, then read the .jsonl file in the transcripts dir.",
"Each JSONL line is a turn. The last few lines show what the main agent is currently doing.",
].join("\n");

const FORK_MODEL = "claude-haiku-4-5-20251001";

/** Run a fork agent — parallel, does NOT touch the main serial queue or main session. */
export async function runFork(prompt: string): Promise<RunResult> {
const { api } = getSettings();

const args = [
"claude", "-p", prompt,
"--output-format", "json",
"--dangerously-skip-permissions",
"--model", FORK_MODEL,
"--append-system-prompt", FORK_SYSTEM_PROMPT,
];

const { CLAUDECODE: _, ...cleanEnv } = process.env;
const baseEnv = { ...cleanEnv } as Record<string, string>;

const exec = await runClaudeOnce(args, FORK_MODEL, api, baseEnv);

let stdout = exec.rawStdout;
if (exec.exitCode === 0) {
try {
const json = JSON.parse(exec.rawStdout);
stdout = json.result ?? exec.rawStdout;
} catch {}
}

return { stdout, stderr: exec.stderr, exitCode: exec.exitCode };
}

/**
* Bootstrap the session: fires Claude with the system prompt so the
* session is created immediately. No-op if a session already exists.
Expand Down