@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
JavaScript
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
};