UNPKG

@stackmemoryai/stackmemory

Version:

Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a

489 lines (487 loc) 15.8 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { spawn, execSync } from "child_process"; import { existsSync, mkdirSync, writeFileSync, appendFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import { logger } from "../../core/monitoring/logger.js"; const STUCK_TIMEOUT_MS = 5 * 60 * 1e3; const LOOP_COOLDOWN_MS = 3e3; const TMP_DRAFT_DIR = join(tmpdir(), "loopmax-drafts"); class LoopMaxRunner { config; state; stateFile; logFile; activeProcess = null; stopped = false; workDir; constructor(config) { this.config = { task: config.task, criteria: config.criteria, cwd: config.cwd || process.cwd(), useWorktree: config.useWorktree ?? true, maxStuckBeforeRespawn: config.maxStuckBeforeRespawn ?? 3, maxLoops: config.maxLoops ?? 0, commitEvery: config.commitEvery ?? 25, model: config.model || "sonnet", verbose: config.verbose ?? true }; if (!existsSync(TMP_DRAFT_DIR)) { mkdirSync(TMP_DRAFT_DIR, { recursive: true }); } this.workDir = this.config.cwd; this.stateFile = join(TMP_DRAFT_DIR, `state-${Date.now()}.json`); this.logFile = join(TMP_DRAFT_DIR, `log-${Date.now()}.jsonl`); this.state = { task: this.config.task, criteria: this.config.criteria, startedAt: Date.now(), loop: 0, totalCommits: 0, iterations: [], status: "running" }; this.saveState(); this.writeHookState(); } /** Main entry — runs forever until criteria met or stopped */ async run() { this.log(`LoopMax starting: ${this.config.task}`); this.log(`Criteria: ${this.config.criteria}`); this.log(`State: ${this.stateFile}`); this.log(`Log: ${this.logFile}`); if (this.config.useWorktree) { await this.setupWorktree(); } const cleanup = () => { this.stopped = true; this.commitAndSummarize("SIGINT received \u2014 saving progress"); if (this.activeProcess) { this.activeProcess.kill("SIGTERM"); } }; process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); let consecutiveStuck = 0; while (!this.stopped) { if (this.config.maxLoops > 0 && this.state.loop >= this.config.maxLoops) { this.log(`Max loops (${this.config.maxLoops}) reached. Stopping.`); break; } this.state.loop++; this.log(` ${"=".repeat(60)}`); this.log(`LOOP ${this.state.loop} starting`); this.log(`${"=".repeat(60)}`); const iteration = await this.runOneLoop(); this.state.iterations.push(iteration); if (iteration.stuck) { consecutiveStuck++; this.log( `Stuck count: ${consecutiveStuck}/${this.config.maxStuckBeforeRespawn}` ); if (consecutiveStuck >= this.config.maxStuckBeforeRespawn) { this.log( "Max stuck reached \u2014 committing, summarizing, respawning fresh" ); this.commitAndSummarize( `Stuck after ${consecutiveStuck} loops \u2014 saving checkpoint` ); consecutiveStuck = 0; } } else { consecutiveStuck = 0; } if (await this.checkCriteria()) { this.log("ALL CRITERIA MET \u2014 loop complete!"); this.state.status = "completed"; this.commitAndSummarize("LoopMax complete \u2014 all criteria met"); break; } this.saveState(); if (!this.stopped) { this.log( `Cooling down ${LOOP_COOLDOWN_MS / 1e3}s before next loop...` ); await sleep(LOOP_COOLDOWN_MS); } } if (this.stopped) { this.state.status = "stopped"; } this.saveState(); this.printSummary(); process.removeListener("SIGINT", cleanup); process.removeListener("SIGTERM", cleanup); } /** Run a single Claude Code loop iteration */ async runOneLoop() { const iteration = { loop: this.state.loop, pid: 0, startedAt: Date.now(), exitCode: null, commitsMade: 0, stuck: false }; const prompt = this.buildPrompt(); const draftFile = join(TMP_DRAFT_DIR, `prompt-loop-${this.state.loop}.md`); writeFileSync(draftFile, prompt); this.log(`Prompt saved to ${draftFile}`); try { const result = await this.spawnClaude(prompt); iteration.pid = result.pid; iteration.exitCode = result.exitCode; iteration.stuck = result.stuck; iteration.endedAt = Date.now(); iteration.commitsMade = this.autoCommit( `loopmax: loop ${this.state.loop} (exit=${result.exitCode})` ); this.state.totalCommits += iteration.commitsMade; } catch (err) { this.log(`Loop ${this.state.loop} error: ${err.message}`); iteration.exitCode = -1; iteration.endedAt = Date.now(); iteration.stuck = true; iteration.commitsMade = this.autoCommit( `loopmax: loop ${this.state.loop} crashed \u2014 saving progress` ); this.state.totalCommits += iteration.commitsMade; } appendFileSync(this.logFile, JSON.stringify(iteration) + "\n"); return iteration; } /** Spawn claude -p with --dangerously-skip-permissions */ spawnClaude(prompt) { return new Promise((resolve, reject) => { const args = [ "-p", prompt, "--dangerously-skip-permissions", "--output-format", "text", "--model", this.config.model ]; this.log(`Spawning: claude ${args.slice(0, 4).join(" ")} ...`); const child = spawn("claude", args, { cwd: this.workDir, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LOOPMAX: "1", LOOPMAX_LOOP: String(this.state.loop), LOOPMAX_STATE: this.stateFile, LOOPMAX_TASK: this.config.task, LOOPMAX_CRITERIA: this.config.criteria, LOOPMAX_MODEL: this.config.model } }); this.activeProcess = child; let lastOutputAt = Date.now(); let stuck = false; let output = ""; const stuckCheck = setInterval(() => { if (Date.now() - lastOutputAt > STUCK_TIMEOUT_MS) { this.log("Stuck detected (no output for 5min) \u2014 killing agent"); stuck = true; child.kill("SIGTERM"); setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 5e3); } }, 3e4); child.stdout?.on("data", (data) => { lastOutputAt = Date.now(); const text = data.toString(); output += text; if (this.config.verbose) { process.stdout.write(text); } }); child.stderr?.on("data", (data) => { lastOutputAt = Date.now(); const text = data.toString(); if (this.config.verbose) { process.stderr.write(text); } }); child.on("error", (err) => { clearInterval(stuckCheck); this.activeProcess = null; reject(err); }); child.on("close", (code) => { clearInterval(stuckCheck); this.activeProcess = null; const outputFile = join( TMP_DRAFT_DIR, `output-loop-${this.state.loop}.txt` ); writeFileSync(outputFile, output); resolve({ pid: child.pid || 0, exitCode: code ?? -1, stuck }); }); }); } /** Build the prompt for Claude Code */ buildPrompt() { const priorContext = this.getPriorContext(); return [ `# Task`, ``, this.config.task, ``, `# Completion Criteria`, ``, this.config.criteria, ``, `# Mode: LoopMax`, ``, `You are in LoopMax mode. Rules:`, `1. DO NOT PLAN. Just start coding immediately.`, `2. Run tests often. Fix what breaks. Repeat.`, `3. Commit to git frequently to preserve your work.`, `4. If tests pass and lint is clean, you're done.`, `5. If you get stuck, commit what you have and describe the blocker.`, `6. Save any drafts or experiments to /tmp/loopmax-drafts/`, `7. Be aggressive \u2014 try things, break things, fix things.`, `8. Do NOT ask for permission. Do NOT explain your reasoning at length.`, `9. Prefer action over analysis. Code over comments.`, ``, `# Working Directory`, ``, this.workDir, ``, priorContext ? `# Prior Context (from previous loops) ${priorContext} ` : "", `# GO. No planning. Just start.` ].filter(Boolean).join("\n"); } /** Get summary of what happened in prior loops */ getPriorContext() { if (this.state.iterations.length === 0) return ""; const recent = this.state.iterations.slice(-3); const lines = recent.map((it) => { const duration = it.endedAt ? Math.round((it.endedAt - it.startedAt) / 1e3) : "?"; const status = it.stuck ? "STUCK" : it.exitCode === 0 ? "OK" : `EXIT=${it.exitCode}`; return `- Loop ${it.loop}: ${status}, ${duration}s, ${it.commitsMade} commits${it.summary ? ` \u2014 ${it.summary}` : ""}`; }); try { const log = execSync("git log --oneline -5", { cwd: this.workDir, encoding: "utf-8", timeout: 5e3 }).trim(); lines.push("", "Recent commits:", log); } catch { } try { execSync("npm run test:run", { cwd: this.workDir, stdio: "pipe", timeout: 12e4 }); lines.push("", "Tests: ALL PASSING"); } catch (err) { const stderr = (err instanceof Error && "stderr" in err && err.stderr != null ? String(err.stderr) : "") || ""; const lastLines = stderr.split("\n").slice(-10).join("\n"); lines.push("", "Tests: FAILING", lastLines); } return lines.join("\n"); } /** Check if completion criteria are met */ async checkCriteria() { try { execSync("npm run test:run", { cwd: this.workDir, stdio: "pipe", timeout: 12e4 }); execSync("npm run lint", { cwd: this.workDir, stdio: "pipe", timeout: 6e4 }); execSync("npm run build", { cwd: this.workDir, stdio: "pipe", timeout: 6e4 }); return true; } catch { return false; } } /** Auto-commit any changes in the working directory */ autoCommit(message) { try { const status = execSync("git status --porcelain", { cwd: this.workDir, encoding: "utf-8", timeout: 1e4 }).trim(); if (!status) return 0; execSync("git add -A", { cwd: this.workDir, stdio: "pipe", timeout: 1e4 }); execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: this.workDir, stdio: "pipe", timeout: 1e4, env: { ...process.env, GIT_AUTHOR_NAME: "LoopMax", GIT_COMMITTER_NAME: "LoopMax" } }); this.log(`Committed: ${message}`); return 1; } catch { return 0; } } /** Commit current state and write a summary */ commitAndSummarize(reason) { this.log(`Checkpoint: ${reason}`); const summaryFile = join( TMP_DRAFT_DIR, `summary-loop-${this.state.loop}.md` ); const summary = [ `# LoopMax Checkpoint`, ``, `**Reason:** ${reason}`, `**Loop:** ${this.state.loop}`, `**Total commits:** ${this.state.totalCommits}`, `**Elapsed:** ${Math.round((Date.now() - this.state.startedAt) / 1e3)}s`, ``, `## Task`, this.config.task, ``, `## Status`, this.state.iterations.slice(-3).map((it) => { const status = it.stuck ? "STUCK" : it.exitCode === 0 ? "OK" : `EXIT=${it.exitCode}`; return `- Loop ${it.loop}: ${status}`; }).join("\n") ].join("\n"); writeFileSync(summaryFile, summary); this.autoCommit(`loopmax: checkpoint \u2014 ${reason}`); this.saveState(); } /** Set up a git worktree for isolated work */ async setupWorktree() { const branch = `loopmax/${Date.now()}`; const worktreePath = join(tmpdir(), `loopmax-wt-${Date.now()}`); this.log(`Creating worktree at ${worktreePath} on branch ${branch}`); try { execSync(`git worktree add -b "${branch}" "${worktreePath}"`, { cwd: this.config.cwd, stdio: "pipe", timeout: 3e4 }); this.workDir = worktreePath; this.state.worktreePath = worktreePath; this.state.worktreeBranch = branch; if (existsSync(join(worktreePath, "package.json"))) { this.log("Installing dependencies in worktree..."); try { execSync("npm install", { cwd: worktreePath, stdio: "pipe", timeout: 12e4 }); } catch { this.log("npm install failed \u2014 continuing anyway"); } } this.log(`Worktree ready: ${worktreePath}`); } catch (err) { this.log(`Worktree creation failed: ${err.message}`); this.log("Falling back to working in current directory"); this.config.useWorktree = false; } } /** Clean up worktree */ async cleanup() { if (this.state.worktreePath && existsSync(this.state.worktreePath)) { this.log(`Cleaning up worktree: ${this.state.worktreePath}`); try { execSync(`git worktree remove "${this.state.worktreePath}" --force`, { cwd: this.config.cwd, stdio: "pipe", timeout: 3e4 }); } catch { this.log("Worktree cleanup failed \u2014 may need manual cleanup"); } } } /** Save state to /tmp */ saveState() { writeFileSync(this.stateFile, JSON.stringify(this.state, null, 2)); } /** Write hook state so the Stop hook can respawn independently */ writeHookState() { const hookState = { task: this.config.task, criteria: this.config.criteria, cwd: this.workDir, loop: this.state.loop, startedAt: this.state.startedAt, iterations: this.state.iterations, model: this.config.model, status: this.state.status }; const hookStateFile = join(TMP_DRAFT_DIR, "hook-state.json"); writeFileSync(hookStateFile, JSON.stringify(hookState, null, 2)); } /** Print final summary */ printSummary() { const elapsed = Math.round((Date.now() - this.state.startedAt) / 1e3); const successLoops = this.state.iterations.filter( (i) => i.exitCode === 0 ).length; const stuckLoops = this.state.iterations.filter((i) => i.stuck).length; console.log("\n" + "=".repeat(60)); console.log("LoopMax Summary"); console.log("=".repeat(60)); console.log(`Status: ${this.state.status}`); console.log(`Total loops: ${this.state.loop}`); console.log(`Successful: ${successLoops}`); console.log(`Stuck: ${stuckLoops}`); console.log(`Commits: ${this.state.totalCommits}`); console.log(`Elapsed: ${elapsed}s`); console.log(`State file: ${this.stateFile}`); console.log(`Log file: ${this.logFile}`); if (this.state.worktreePath) { console.log(`Worktree: ${this.state.worktreePath}`); console.log(`Branch: ${this.state.worktreeBranch}`); } console.log("=".repeat(60)); } log(msg) { const ts = (/* @__PURE__ */ new Date()).toISOString().substring(11, 19); const line = `[${ts}] [loopmax] ${msg}`; if (this.config.verbose) { console.log(line); } logger.info(msg, { component: "loopmax", loop: this.state.loop }); } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } export { LoopMaxRunner };