mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
chore: bump version to 0.1658
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user