mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49: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`)
|
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`
|
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
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codeman",
|
"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",
|
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+78
-48
@@ -7,6 +7,10 @@
|
|||||||
*
|
*
|
||||||
* Follows the same lifecycle pattern as ImageWatcher/SubagentWatcher:
|
* Follows the same lifecycle pattern as ImageWatcher/SubagentWatcher:
|
||||||
* extends EventEmitter, start()/stop(), emits typed events.
|
* extends EventEmitter, start()/stop(), emits typed events.
|
||||||
|
*
|
||||||
|
* Lifecycle states:
|
||||||
|
* IDLE → STARTING → RUNNING → (crash) → RESTARTING → STARTING → ...
|
||||||
|
* Any state → stop() → IDLE
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
@@ -33,6 +37,9 @@ const URL_TIMEOUT_MS = 30_000;
|
|||||||
/** Restart delay after unexpected exit (ms) */
|
/** Restart delay after unexpected exit (ms) */
|
||||||
const RESTART_DELAY_MS = 5_000;
|
const RESTART_DELAY_MS = 5_000;
|
||||||
|
|
||||||
|
/** Force-kill timeout after SIGTERM (ms) */
|
||||||
|
const FORCE_KILL_MS = 5_000;
|
||||||
|
|
||||||
// ========== TunnelManager Class ==========
|
// ========== TunnelManager Class ==========
|
||||||
|
|
||||||
export class TunnelManager extends EventEmitter {
|
export class TunnelManager extends EventEmitter {
|
||||||
@@ -41,7 +48,9 @@ export class TunnelManager extends EventEmitter {
|
|||||||
private cloudflaredPath: string | null = null;
|
private cloudflaredPath: string | null = null;
|
||||||
private urlTimeoutTimer: NodeJS.Timeout | null = null;
|
private urlTimeoutTimer: NodeJS.Timeout | null = null;
|
||||||
private restartTimer: 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 localPort = 3000;
|
||||||
private useHttps = false;
|
private useHttps = false;
|
||||||
|
|
||||||
@@ -71,6 +80,22 @@ export class TunnelManager extends EventEmitter {
|
|||||||
return 'cloudflared';
|
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.
|
* Start the cloudflared tunnel process.
|
||||||
*/
|
*/
|
||||||
@@ -79,7 +104,9 @@ export class TunnelManager extends EventEmitter {
|
|||||||
return; // Already running
|
return; // Already running
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stopping = false;
|
// Cancel any pending restart — we're starting fresh
|
||||||
|
this.clearTimers();
|
||||||
|
this.stopped = false;
|
||||||
this.localPort = localPort;
|
this.localPort = localPort;
|
||||||
this.useHttps = https;
|
this.useHttps = https;
|
||||||
|
|
||||||
@@ -147,31 +174,40 @@ export class TunnelManager extends EventEmitter {
|
|||||||
this.process.stdout?.on('data', handleOutput);
|
this.process.stdout?.on('data', handleOutput);
|
||||||
this.process.stderr?.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) => {
|
this.process.on('error', (err) => {
|
||||||
|
if (exited) return;
|
||||||
|
exited = true;
|
||||||
console.error(`[TunnelManager] Process error:`, err.message);
|
console.error(`[TunnelManager] Process error:`, err.message);
|
||||||
this.cleanup();
|
this.process = null;
|
||||||
|
this.url = null;
|
||||||
this.emit('error', `cloudflared error: ${err.message}`);
|
this.emit('error', `cloudflared error: ${err.message}`);
|
||||||
|
this.maybeScheduleRestart();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.process.on('exit', (code, signal) => {
|
this.process.on('exit', (code, signal) => {
|
||||||
|
if (exited) return;
|
||||||
|
exited = true;
|
||||||
console.log(`[TunnelManager] Process exited (code=${code}, signal=${signal})`);
|
console.log(`[TunnelManager] Process exited (code=${code}, signal=${signal})`);
|
||||||
const wasRunning = this.url !== null;
|
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) {
|
if (this.stopped) {
|
||||||
// Unexpected exit — attempt restart
|
// 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})`);
|
this.emit('error', `cloudflared exited unexpectedly (code=${code})`);
|
||||||
if (wasRunning) {
|
if (wasRunning) {
|
||||||
console.log(`[TunnelManager] Scheduling restart in ${RESTART_DELAY_MS}ms`);
|
this.maybeScheduleRestart();
|
||||||
this.restartTimer = setTimeout(() => {
|
|
||||||
this.restartTimer = null;
|
|
||||||
if (!this.stopping && !this.process) {
|
|
||||||
this.start(this.localPort, this.useHttps);
|
|
||||||
}
|
|
||||||
}, RESTART_DELAY_MS);
|
|
||||||
}
|
}
|
||||||
} 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 {
|
stop(): void {
|
||||||
this.stopping = true;
|
this.stopped = true;
|
||||||
|
this.clearTimers();
|
||||||
if (this.restartTimer) {
|
|
||||||
clearTimeout(this.restartTimer);
|
|
||||||
this.restartTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.urlTimeoutTimer) {
|
|
||||||
clearTimeout(this.urlTimeoutTimer);
|
|
||||||
this.urlTimeoutTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.process) {
|
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 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 {
|
try {
|
||||||
if (pid) process.kill(pid, 'SIGKILL');
|
if (pid) process.kill(pid, 'SIGKILL');
|
||||||
} catch {
|
} catch {
|
||||||
// Process already gone
|
// Process already gone
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, FORCE_KILL_MS);
|
||||||
this.process.once('exit', () => clearTimeout(forceTimer));
|
|
||||||
} else {
|
} else {
|
||||||
this.cleanup();
|
// No process running (maybe in restart delay) — just emit stopped
|
||||||
|
this.url = null;
|
||||||
this.emit('stopped', {});
|
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 {
|
isRunning(): boolean {
|
||||||
return this.process !== null;
|
return this.process !== null || this.restartTimer !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getUrl(): string | null {
|
getUrl(): string | null {
|
||||||
@@ -241,7 +271,7 @@ export class TunnelManager extends EventEmitter {
|
|||||||
|
|
||||||
getStatus(): TunnelStatus {
|
getStatus(): TunnelStatus {
|
||||||
return {
|
return {
|
||||||
running: this.process !== null,
|
running: this.process !== null || this.restartTimer !== null,
|
||||||
url: this.url,
|
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>
|
<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
|
Run OpenCode
|
||||||
</button>
|
</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>
|
||||||
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
||||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||||
|
|||||||
@@ -418,3 +418,19 @@ export const PushSubscribeSchema = z.object({
|
|||||||
export const PushPreferencesUpdateSchema = z.object({
|
export const PushPreferencesUpdateSchema = z.object({
|
||||||
pushPreferences: z.record(z.string(), z.boolean()),
|
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,
|
RespawnEnableSchema,
|
||||||
PushSubscribeSchema,
|
PushSubscribeSchema,
|
||||||
PushPreferencesUpdateSchema,
|
PushPreferencesUpdateSchema,
|
||||||
|
RalphLoopStartSchema,
|
||||||
isValidWorkingDir,
|
isValidWorkingDir,
|
||||||
} from './schemas.js';
|
} from './schemas.js';
|
||||||
import { StaleExpirationMap } from '../utils/index.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)
|
// Use enhanced PlanItem from orchestrator (has verification, dependencies, tracking)
|
||||||
type PlanItem = import('../plan-orchestrator.js').PlanItem;
|
type PlanItem = import('../plan-orchestrator.js').PlanItem;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user