From 8038145d4067f452b9f59f5214c2c0fc3418b32c Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 27 Feb 2026 08:53:30 +0100 Subject: [PATCH] chore: bump version to 0.1658 --- CLAUDE.md | 2 +- package.json | 2 +- src/tunnel-manager.ts | 126 ++++++++++++++--------- src/web/public/index.html | 4 + src/web/schemas.ts | 16 +++ src/web/server.ts | 205 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 305 insertions(+), 50 deletions(-) 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;