diff --git a/CLAUDE.md b/CLAUDE.md
index 8dfecb3c..62555567 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -35,7 +35,7 @@ When user says "COM":
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart codeman-web`
-**Version**: 0.1657 (must match `package.json`)
+**Version**: 0.1658 (must match `package.json`)
## Project Overview
diff --git a/package.json b/package.json
index c055bc2f..a7154109 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codeman",
- "version": "0.1657",
+ "version": "0.1658",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
diff --git a/src/tunnel-manager.ts b/src/tunnel-manager.ts
index e0975f56..62a9f03c 100644
--- a/src/tunnel-manager.ts
+++ b/src/tunnel-manager.ts
@@ -7,6 +7,10 @@
*
* Follows the same lifecycle pattern as ImageWatcher/SubagentWatcher:
* extends EventEmitter, start()/stop(), emits typed events.
+ *
+ * Lifecycle states:
+ * IDLE → STARTING → RUNNING → (crash) → RESTARTING → STARTING → ...
+ * Any state → stop() → IDLE
*/
import { EventEmitter } from 'node:events';
@@ -33,6 +37,9 @@ const URL_TIMEOUT_MS = 30_000;
/** Restart delay after unexpected exit (ms) */
const RESTART_DELAY_MS = 5_000;
+/** Force-kill timeout after SIGTERM (ms) */
+const FORCE_KILL_MS = 5_000;
+
// ========== TunnelManager Class ==========
export class TunnelManager extends EventEmitter {
@@ -41,7 +48,9 @@ export class TunnelManager extends EventEmitter {
private cloudflaredPath: string | null = null;
private urlTimeoutTimer: NodeJS.Timeout | null = null;
private restartTimer: NodeJS.Timeout | null = null;
- private stopping = false;
+ private forceKillTimer: NodeJS.Timeout | null = null;
+ /** True when the user explicitly requested stop — suppresses auto-restart */
+ private stopped = true;
private localPort = 3000;
private useHttps = false;
@@ -71,6 +80,22 @@ export class TunnelManager extends EventEmitter {
return 'cloudflared';
}
+ /** Clear all pending timers */
+ private clearTimers(): void {
+ if (this.restartTimer) {
+ clearTimeout(this.restartTimer);
+ this.restartTimer = null;
+ }
+ if (this.urlTimeoutTimer) {
+ clearTimeout(this.urlTimeoutTimer);
+ this.urlTimeoutTimer = null;
+ }
+ if (this.forceKillTimer) {
+ clearTimeout(this.forceKillTimer);
+ this.forceKillTimer = null;
+ }
+ }
+
/**
* Start the cloudflared tunnel process.
*/
@@ -79,7 +104,9 @@ export class TunnelManager extends EventEmitter {
return; // Already running
}
- this.stopping = false;
+ // Cancel any pending restart — we're starting fresh
+ this.clearTimers();
+ this.stopped = false;
this.localPort = localPort;
this.useHttps = https;
@@ -147,31 +174,40 @@ export class TunnelManager extends EventEmitter {
this.process.stdout?.on('data', handleOutput);
this.process.stderr?.on('data', handleOutput);
+ // Guard: both 'error' and 'exit' can fire — only handle once
+ let exited = false;
+
this.process.on('error', (err) => {
+ if (exited) return;
+ exited = true;
console.error(`[TunnelManager] Process error:`, err.message);
- this.cleanup();
+ this.process = null;
+ this.url = null;
this.emit('error', `cloudflared error: ${err.message}`);
+ this.maybeScheduleRestart();
});
this.process.on('exit', (code, signal) => {
+ if (exited) return;
+ exited = true;
console.log(`[TunnelManager] Process exited (code=${code}, signal=${signal})`);
const wasRunning = this.url !== null;
- this.cleanup();
+ this.process = null;
+ this.url = null;
+ if (this.forceKillTimer) {
+ clearTimeout(this.forceKillTimer);
+ this.forceKillTimer = null;
+ }
- if (!this.stopping) {
- // Unexpected exit — attempt restart
+ if (this.stopped) {
+ // User requested stop — clean exit
+ this.emit('stopped', {});
+ } else {
+ // Unexpected exit — attempt restart if the tunnel had been working
this.emit('error', `cloudflared exited unexpectedly (code=${code})`);
if (wasRunning) {
- console.log(`[TunnelManager] Scheduling restart in ${RESTART_DELAY_MS}ms`);
- this.restartTimer = setTimeout(() => {
- this.restartTimer = null;
- if (!this.stopping && !this.process) {
- this.start(this.localPort, this.useHttps);
- }
- }, RESTART_DELAY_MS);
+ this.maybeScheduleRestart();
}
- } else {
- this.emit('stopped', {});
}
});
@@ -185,54 +221,48 @@ export class TunnelManager extends EventEmitter {
}
/**
- * Stop the cloudflared tunnel process.
+ * Schedule an auto-restart if the user hasn't requested stop.
+ */
+ private maybeScheduleRestart(): void {
+ if (this.stopped || this.restartTimer || this.process) return;
+ console.log(`[TunnelManager] Scheduling restart in ${RESTART_DELAY_MS}ms`);
+ this.restartTimer = setTimeout(() => {
+ this.restartTimer = null;
+ if (!this.stopped && !this.process) {
+ this.start(this.localPort, this.useHttps);
+ }
+ }, RESTART_DELAY_MS);
+ }
+
+ /**
+ * Stop the cloudflared tunnel process. Safe to call from any state.
*/
stop(): void {
- this.stopping = true;
-
- if (this.restartTimer) {
- clearTimeout(this.restartTimer);
- this.restartTimer = null;
- }
-
- if (this.urlTimeoutTimer) {
- clearTimeout(this.urlTimeoutTimer);
- this.urlTimeoutTimer = null;
- }
+ this.stopped = true;
+ this.clearTimers();
if (this.process) {
- console.log(`[TunnelManager] Stopping tunnel (PID ${this.process.pid})`);
- this.process.kill('SIGTERM');
- // Force kill after 5s if still alive
const pid = this.process.pid;
- const forceTimer = setTimeout(() => {
+ console.log(`[TunnelManager] Stopping tunnel (PID ${pid})`);
+ this.process.kill('SIGTERM');
+ // Force kill after timeout if still alive
+ this.forceKillTimer = setTimeout(() => {
+ this.forceKillTimer = null;
try {
if (pid) process.kill(pid, 'SIGKILL');
} catch {
// Process already gone
}
- }, 5000);
- this.process.once('exit', () => clearTimeout(forceTimer));
+ }, FORCE_KILL_MS);
} else {
- this.cleanup();
+ // No process running (maybe in restart delay) — just emit stopped
+ this.url = null;
this.emit('stopped', {});
}
}
- /**
- * Clean up internal state after process exit.
- */
- private cleanup(): void {
- this.process = null;
- this.url = null;
- if (this.urlTimeoutTimer) {
- clearTimeout(this.urlTimeoutTimer);
- this.urlTimeoutTimer = null;
- }
- }
-
isRunning(): boolean {
- return this.process !== null;
+ return this.process !== null || this.restartTimer !== null;
}
getUrl(): string | null {
@@ -241,7 +271,7 @@ export class TunnelManager extends EventEmitter {
getStatus(): TunnelStatus {
return {
- running: this.process !== null,
+ running: this.process !== null || this.restartTimer !== null,
url: this.url,
};
}
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 32a29852..572ef806 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -247,6 +247,10 @@
Run OpenCode
+
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index 0e014460..3c6fdadc 100644
--- a/src/web/schemas.ts
+++ b/src/web/schemas.ts
@@ -418,3 +418,19 @@ export const PushSubscribeSchema = z.object({
export const PushPreferencesUpdateSchema = z.object({
pushPreferences: z.record(z.string(), z.boolean()),
});
+
+// ========== Ralph Loop ==========
+
+/** POST /api/ralph-loop/start */
+export const RalphLoopStartSchema = z.object({
+ caseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format').optional().default('testcase'),
+ taskDescription: z.string().min(1).max(100000),
+ completionPhrase: z.string().max(100).default('COMPLETE'),
+ maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
+ enableRespawn: z.boolean().default(false),
+ planItems: z.array(z.object({
+ content: z.string(),
+ priority: z.string().optional(),
+ enabled: z.boolean().default(true),
+ })).optional(),
+});
diff --git a/src/web/server.ts b/src/web/server.ts
index 06341d52..7d85254d 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -96,6 +96,7 @@ import {
RespawnEnableSchema,
PushSubscribeSchema,
PushPreferencesUpdateSchema,
+ RalphLoopStartSchema,
isValidWorkingDir,
} from './schemas.js';
import { StaleExpirationMap } from '../utils/index.js';
@@ -2927,6 +2928,210 @@ export class WebServer extends EventEmitter {
}
});
+ // ========== Ralph Loop Start (replaces 6-8 serial API calls from frontend) ==========
+
+ this.app.post('/api/ralph-loop/start', async (req): Promise
=> {
+ // Prevent unbounded session creation
+ if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
+ return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`);
+ }
+
+ const rlResult = RalphLoopStartSchema.safeParse(req.body);
+ if (!rlResult.success) {
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, rlResult.error.issues[0]?.message ?? 'Validation failed');
+ }
+ const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data;
+
+ const casePath = join(casesDir, caseName);
+
+ // Security: Path traversal protection
+ const rlResolvedPath = resolve(casePath);
+ const rlResolvedBase = resolve(casesDir);
+ const rlRelPath = relative(rlResolvedBase, rlResolvedPath);
+ if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) {
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
+ }
+
+ // Create case folder if it doesn't exist (reuse quick-start logic)
+ if (!existsSync(casePath)) {
+ try {
+ mkdirSync(casePath, { recursive: true });
+ mkdirSync(join(casePath, 'src'), { recursive: true });
+ const templatePath = await this.getDefaultClaudeMdPath();
+ const claudeMd = generateClaudeMd(caseName, '', templatePath);
+ writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
+ await writeHooksConfig(casePath);
+ this.broadcast('case:created', { name: caseName, path: casePath });
+ } catch (err) {
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
+ }
+ }
+
+ // Create session
+ const niceConfig = await this.getGlobalNiceConfig();
+ const rlModelConfig = await this.getModelConfig();
+ const rlClaudeModeConfig = await this.getClaudeModeConfig();
+ const session = new Session({
+ workingDir: casePath,
+ mux: this.mux,
+ useMux: true,
+ mode: 'claude',
+ niceConfig,
+ model: rlModelConfig?.defaultModel,
+ claudeMode: rlClaudeModeConfig.claudeMode,
+ allowedTools: rlClaudeModeConfig.allowedTools,
+ });
+
+ // Configure Ralph tracker
+ autoConfigureRalph(session, casePath, () => {});
+ if (!session.ralphTracker.enabled) {
+ session.ralphTracker.enable();
+ session.ralphTracker.enableAutoEnable();
+ }
+ session.ralphTracker.startLoop(completionPhrase, maxIterations ?? undefined);
+
+ // Build fix_plan markdown from plan items if provided
+ const enabledItems = planItems?.filter(i => i.enabled) ?? [];
+ let planContent = '';
+ if (enabledItems.length > 0) {
+ const p0 = enabledItems.filter(i => i.priority === 'P0');
+ const p1 = enabledItems.filter(i => i.priority === 'P1');
+ const p2 = enabledItems.filter(i => i.priority === 'P2');
+ const noPri = enabledItems.filter(i => !i.priority);
+ planContent = '# Implementation Plan\n\n';
+ planContent += `Generated: ${new Date().toISOString().slice(0, 10)}\n\n`;
+ if (p0.length > 0) { planContent += '## Critical Path (P0)\n\n'; p0.forEach(i => { planContent += `- [ ] ${i.content}\n`; }); planContent += '\n'; }
+ if (p1.length > 0) { planContent += '## Standard (P1)\n\n'; p1.forEach(i => { planContent += `- [ ] ${i.content}\n`; }); planContent += '\n'; }
+ if (p2.length > 0) { planContent += '## Nice-to-Have (P2)\n\n'; p2.forEach(i => { planContent += `- [ ] ${i.content}\n`; }); planContent += '\n'; }
+ if (noPri.length > 0) { planContent += '## Tasks\n\n'; noPri.forEach(i => { planContent += `- [ ] ${i.content}\n`; }); planContent += '\n'; }
+
+ // Import into tracker and write to disk
+ session.ralphTracker.importFixPlanMarkdown(planContent);
+ const fixPlanPath = join(casePath, '@fix_plan.md');
+ writeFileSync(fixPlanPath, planContent, 'utf-8');
+ }
+
+ // Build full prompt
+ const hasPlan = enabledItems.length > 0;
+ let fullPrompt = taskDescription + '\n\n---\n\n';
+ if (hasPlan) {
+ fullPrompt += '## Task Plan\n\n';
+ fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n';
+ fullPrompt += '- Reference the plan at the start of each iteration\n';
+ fullPrompt += '- Update task checkboxes as you complete items\n';
+ fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n';
+ }
+ fullPrompt += '## Iteration Protocol\n\n';
+ fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n';
+ fullPrompt += '1. Check what work has already been done\n';
+ fullPrompt += '2. Make incremental progress toward completion\n';
+ fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n';
+ fullPrompt += '## Verification\n\n';
+ fullPrompt += 'After each significant change:\n';
+ fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n';
+ fullPrompt += '- Check for type/lint errors if applicable\n';
+ fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n';
+ fullPrompt += '## Completion Criteria\n\n';
+ fullPrompt += `Output \`${completionPhrase}\` when ALL of the following are true:\n`;
+ fullPrompt += '- All requirements from the task description are implemented\n';
+ fullPrompt += '- All tests pass\n';
+ fullPrompt += '- Changes are committed\n\n';
+ fullPrompt += '## If Stuck\n\n';
+ fullPrompt += 'If you encounter the same error for 3+ iterations:\n';
+ fullPrompt += '1. Document what you\'ve tried\n';
+ fullPrompt += '2. Identify the specific blocker\n';
+ fullPrompt += '3. Try an alternative approach\n';
+ fullPrompt += '4. If truly blocked, output `BLOCKED` with an explanation\n';
+
+ // Write prompt to file
+ const promptPath = join(casePath, '@ralph_prompt.md');
+ writeFileSync(promptPath, fullPrompt, 'utf-8');
+
+ // Register session
+ this.sessions.set(session.id, session);
+ this.store.incrementSessionsCreated();
+ this.persistSessionState(session);
+ await this.setupSessionListeners(session);
+ getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'ralph_loop_start' });
+ this.broadcast('session:created', this.getSessionStateWithRespawn(session));
+
+ // Start interactive mode
+ try {
+ await session.startInteractive();
+ getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode: 'claude' });
+ this.broadcast('session:interactive', { id: session.id, mode: 'claude' });
+ this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
+ } catch (err) {
+ await this.cleanupSession(session.id, true, 'ralph_loop_start_error');
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
+ }
+
+ // Enable respawn if requested
+ if (enableRespawn) {
+ const ralphUpdatePrompt = 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.';
+ const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, continue on the next uncompleted task, output ${completionPhrase} when ALL tasks are complete.`;
+ const controller = new RespawnController(session, {
+ updatePrompt: ralphUpdatePrompt,
+ sendClear: true,
+ sendInit: true,
+ kickstartPrompt: ralphKickstartPrompt,
+ });
+ this.respawnControllers.set(session.id, controller);
+ this.setupRespawnListeners(session.id, controller);
+ controller.start();
+ this.saveRespawnConfig(session.id, controller.getConfig());
+ this.persistSessionState(session);
+ this.broadcast('respawn:started', { sessionId: session.id, status: controller.getStatus() });
+ }
+
+ // Save lastUsedCase
+ try {
+ const settingsFilePath = join(homedir(), '.codeman', 'settings.json');
+ let settings: Record = {};
+ try { settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8')); } catch { /* ignore */ }
+ settings.lastUsedCase = caseName;
+ const dir = dirname(settingsFilePath);
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
+ fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch(() => {});
+ } catch { /* non-critical */ }
+
+ const sessionId = session.id;
+
+ // Async: poll for CLI readiness, then send prompt
+ setImmediate(() => {
+ const pollReady = async () => {
+ let ready = false;
+ for (let attempt = 0; attempt < 60; attempt++) {
+ await new Promise(r => setTimeout(r, 500));
+ const s = this.sessions.get(sessionId);
+ if (!s) return; // session was deleted
+ // Check terminal output for prompt indicator
+ const termBuf = s.getTerminalBuffer().slice(-2048);
+ if (termBuf.includes('❯') || termBuf.includes('tokens')) {
+ ready = true;
+ break;
+ }
+ }
+ // Small extra delay for CLI to settle
+ await new Promise(r => setTimeout(r, 2000));
+ const s = this.sessions.get(sessionId);
+ if (!s) return;
+ try {
+ await s.writeViaMux('Read @ralph_prompt.md and follow the instructions. Start working immediately.\r');
+ } catch (err) {
+ console.warn(`[RalphLoop] Failed to send prompt to session ${sessionId}:`, getErrorMessage(err));
+ }
+ };
+ pollReady().catch(err => console.error('[RalphLoop] pollReady error:', err));
+ });
+
+ return {
+ success: true,
+ sessionId,
+ caseName,
+ };
+ });
+
// Use enhanced PlanItem from orchestrator (has verification, dependencies, tracking)
type PlanItem = import('../plan-orchestrator.js').PlanItem;