chore: bump version to 0.1658

This commit is contained in:
arkon
2026-02-27 08:53:30 +01:00
parent 63d752e3cf
commit 8038145d40
6 changed files with 305 additions and 50 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+78 -48
View File
@@ -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,
};
}
+4
View File
@@ -247,6 +247,10 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run OpenCode
</button>
<button class="welcome-btn welcome-btn-ralph" onclick="app.showRalphWizard()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/><polyline points="21 3 21 9 15 9"/></svg>
Start Ralph Loop
</button>
</div>
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
+16
View File
@@ -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(),
});
+205
View File
@@ -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<ApiResponse> => {
// 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 \`<promise>${completionPhrase}</promise>\` 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 `<promise>BLOCKED</promise>` 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 <promise>${completionPhrase}</promise> 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<string, unknown> = {};
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;