mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
refactor: SSE event handlers, LLM context optimization, @fileoverview docs (#29)
* refactor: extract SSE event handlers into named class methods Replace ~80 inline addListener closures in connectSSE() with a declarative _SSE_HANDLER_MAP array that drives registration in a single loop. Each handler is now a named _on* method on CodemanApp, making them individually addressable for LLM navigation. Add SSE_EVENTS constant object in constants.js to eliminate magic event-type strings scattered across the frontend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: fix inaccuracies in CLAUDE.md - Fix types barrel path: src/types.ts → src/types/index.ts - Update app.js line count: ~12K → ~11.5K - Correct route handler counts (113 → 111, per-group fixes) - Add code style, ESM gotcha, env vars, route test, lifecycle log docs - Add Node 22 CI note, test teardown timeout, port range Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add mobile screenshots and QR auth security writeup to README Add 3 mobile screenshots (landing, idle, active) and expand the mobile section with QR auth security design details and a touch-optimized interface subsection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: bundle xterm-zerolag-input as vendor IIFE and add pre-commit hook Build and postinstall now bundle the local xterm-zerolag-input package as an IIFE at vendor/xterm-zerolag-input.js with global LocalEchoOverlay shim. Add git pre-commit hook that runs prettier --check on staged .ts files to catch format issues before CI. Also bump constants.js and app.js cache-bust versions to 0.3.0 and add tunnel upload URL display row in settings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add cloudflared install support and interactive launch menu - Add optional cloudflared dependency detection and installation across 6 distro families (macOS, Debian, Fedora, Arch, Alpine, SUSE) - Add tunnel systemd service setup helper - Replace post-install instructions with interactive launch menu (run now / systemd service / skip) - Uninstall now cleans up both codeman-web and codeman-tunnel services Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: gitignore readme-preview.mjs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: WIP — SSE event constants, @fileoverview docs, CLAUDE.md compression - Migrate broadcast() string literals → SseEvent.* typed constants - Add @fileoverview with cross-domain references to all 13 type domain files - Add @fileoverview to frontend JS modules (constants, mobile, voice, etc.) - Add section dividers to route files for LLM scanability - Compress CLAUDE.md: flat file list → domain table, fix counts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: optimize codebase for LLM context window efficiency CLAUDE.md: 456 → 309 lines (32% reduction) - Merge Commands into compact table, remove redundant bash block - Convert Security section to dense table format - Merge Performance + Resource Limits, Debugging + Troubleshooting - Compress Tunnel, Memory Leak, Scripts, Screenshots sections - Remove Key Patterns that duplicate @fileoverview in source files Backend @fileoverview enhancements (10 priority files): - session.ts: key methods, events, cross-domain refs - respawn-controller.ts: state machine, idle detection layers - ralph-tracker.ts: exports, circuit breaker, events - ralph-loop.ts: lifecycle, persistence, events - subagent-watcher.ts: watched patterns, teammate detection - server.ts: coordination list, port interfaces - state-store.ts: dual-file persistence, migration - session-manager.ts: lifecycle methods, mutex guard - hooks-config.ts: hook events list, categories - sse-events.ts: category breakdown (~90 events, 17 categories) Frontend app.js: add 6 section dividers, update @fileoverview line refs Fix: escape glob `*/` in JSDoc that broke ESLint parser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR #29 review bugs - server.ts: replace hardcoded 'session:needsRefresh' with SseEvent constant - install.sh: fix Alpine cloudflared install for non-root (download to tmpfile first) - install.sh: replace Arch pacman (AUR-only) with direct binary download - index.html: bump all 8 remaining cache-bust versions from v0.2.9 to v0.3.0 - mobile-handlers.js: fix @dependency annotation (keyboard-accessory.js, not constants.js) - types/push.ts: fix layer number (4, not 5) - subagent-watcher.ts: fix watched pattern path to include {session} segment - constants.js: fix SSE_EVENTS count in @fileoverview (~73, not ~65) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,10 +15,15 @@ import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
|
||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
|
||||
// ============ Case CRUD ============
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case CRUD (list, create, link, detail, fix-plan)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== List Cases ==========
|
||||
|
||||
app.get('/api/cases', async (): Promise<CaseInfo[]> => {
|
||||
const cases: CaseInfo[] = [];
|
||||
@@ -95,7 +100,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||
await writeHooksConfig(casePath);
|
||||
|
||||
ctx.broadcast('case:created', { name, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||
|
||||
return { success: true, data: { case: { name, path: casePath } } };
|
||||
} catch (err) {
|
||||
@@ -152,7 +157,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
mkdirSync(codemanDir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2));
|
||||
ctx.broadcast('case:linked', { name, path: expandedPath });
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
|
||||
return { success: true, data: { case: { name, path: expandedPath } } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
@@ -321,7 +326,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Ralph Wizard File Endpoints ============
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Ralph Wizard Files (per-case prompt/result serving)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== List Wizard Files ==========
|
||||
|
||||
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
||||
const { caseName } = req.params as { caseName: string };
|
||||
|
||||
@@ -18,10 +18,15 @@ import {
|
||||
PlanTaskAddSchema,
|
||||
} from '../schemas.js';
|
||||
import { findSessionOrFail, CASES_DIR } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
export function registerPlanRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort): void {
|
||||
// ============ Plan Generation Endpoints ============
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Generation (simple AI + detailed orchestration)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Generate Plan (Simple) ==========
|
||||
|
||||
app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
|
||||
const gpResult = GeneratePlanSchema.safeParse(req.body);
|
||||
@@ -215,8 +220,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
});
|
||||
|
||||
// Generate detailed implementation plan using subagent orchestration
|
||||
// This spawns multiple specialist subagents in parallel for thorough analysis
|
||||
// ========== Generate Plan (Detailed Orchestration) ==========
|
||||
|
||||
app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => {
|
||||
const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body);
|
||||
if (!gpdResult.success) {
|
||||
@@ -257,7 +262,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
ctx.activePlanOrchestrators.set(orchestratorId, orchestrator);
|
||||
|
||||
// Broadcast the orchestrator ID so frontend can cancel if needed
|
||||
ctx.broadcast('plan:started', { orchestratorId });
|
||||
ctx.broadcast(SseEvent.PlanStarted, { orchestratorId });
|
||||
|
||||
// Track progress for SSE updates
|
||||
const progressUpdates: Array<{ phase: string; detail: string; timestamp: number }> = [];
|
||||
@@ -265,7 +270,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
const update = { phase, detail, timestamp: Date.now() };
|
||||
progressUpdates.push(update);
|
||||
// Broadcast progress to connected clients
|
||||
ctx.broadcast('plan:progress', update);
|
||||
ctx.broadcast(SseEvent.PlanProgress, update);
|
||||
};
|
||||
|
||||
// Broadcast plan subagent events for UI visibility
|
||||
@@ -280,7 +285,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
}) => {
|
||||
ctx.broadcast('plan:subagent', event);
|
||||
ctx.broadcast(SseEvent.PlanSubagent, event);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -292,7 +297,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
// Clean up orchestrator from active map
|
||||
ctx.activePlanOrchestrators.delete(orchestratorId);
|
||||
ctx.broadcast('plan:completed', { orchestratorId, success: result.success });
|
||||
ctx.broadcast(SseEvent.PlanCompleted, { orchestratorId, success: result.success });
|
||||
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed');
|
||||
@@ -311,7 +316,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
} catch (err) {
|
||||
// Clean up on error too
|
||||
ctx.activePlanOrchestrators.delete(orchestratorId);
|
||||
ctx.broadcast('plan:completed', {
|
||||
ctx.broadcast(SseEvent.PlanCompleted, {
|
||||
orchestratorId,
|
||||
success: false,
|
||||
error: getErrorMessage(err),
|
||||
@@ -323,7 +328,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel active plan generation
|
||||
// ========== Cancel Plan Generation ==========
|
||||
|
||||
app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => {
|
||||
const cpResult = CancelPlanSchema.safeParse(req.body);
|
||||
if (!cpResult.success) {
|
||||
@@ -340,7 +346,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
console.log(`[API] Cancelling plan generation ${orchestratorId}`);
|
||||
await orchestrator.cancel();
|
||||
ctx.activePlanOrchestrators.delete(orchestratorId);
|
||||
ctx.broadcast('plan:cancelled', { orchestratorId });
|
||||
ctx.broadcast(SseEvent.PlanCancelled, { orchestratorId });
|
||||
return { success: true, data: { cancelled: orchestratorId } };
|
||||
}
|
||||
|
||||
@@ -350,17 +356,19 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
console.log(`[API] Cancelling plan generation ${id}`);
|
||||
await orchestrator.cancel();
|
||||
cancelled.push(id);
|
||||
ctx.broadcast('plan:cancelled', { orchestratorId: id });
|
||||
ctx.broadcast(SseEvent.PlanCancelled, { orchestratorId: id });
|
||||
}
|
||||
ctx.activePlanOrchestrators.clear();
|
||||
|
||||
return { success: true, data: { cancelled } };
|
||||
});
|
||||
|
||||
// ============ Plan Management Endpoints ============
|
||||
// These endpoints support runtime plan adaptation with checkpoints, failure tracking, and versioning
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Management (task CRUD, checkpoints, version history, rollback)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Update Plan Task ==========
|
||||
|
||||
// Update a specific plan task (status, attempts, errors)
|
||||
app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => {
|
||||
const { id, taskId } = req.params as { id: string; taskId: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -385,11 +393,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Task not found');
|
||||
}
|
||||
|
||||
ctx.broadcast('session:planTaskUpdate', { sessionId: id, taskId, update: result.task });
|
||||
ctx.broadcast(SseEvent.SessionPlanTaskUpdate, { sessionId: id, taskId, update: result.task });
|
||||
return { success: true, data: result.task };
|
||||
});
|
||||
|
||||
// Trigger a checkpoint review (at iterations 5, 10, 20, etc.)
|
||||
// ========== Create Checkpoint ==========
|
||||
|
||||
app.post('/api/sessions/:id/plan/checkpoint', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -400,11 +409,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
const checkpoint = tracker.generateCheckpointReview();
|
||||
ctx.broadcast('session:planCheckpoint', { sessionId: id, checkpoint });
|
||||
ctx.broadcast(SseEvent.SessionPlanCheckpoint, { sessionId: id, checkpoint });
|
||||
return { success: true, data: checkpoint };
|
||||
});
|
||||
|
||||
// Get plan version history
|
||||
// ========== Get Version History ==========
|
||||
|
||||
app.get('/api/sessions/:id/plan/history', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -417,7 +427,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return { success: true, data: tracker.getPlanHistory() };
|
||||
});
|
||||
|
||||
// Rollback to a previous plan version
|
||||
// ========== Rollback to Version ==========
|
||||
|
||||
app.post('/api/sessions/:id/plan/rollback/:version', async (req) => {
|
||||
const { id, version } = req.params as { id: string; version: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -432,11 +443,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Version not found');
|
||||
}
|
||||
|
||||
ctx.broadcast('session:planRollback', { sessionId: id, version: parseInt(version, 10) });
|
||||
ctx.broadcast(SseEvent.SessionPlanRollback, { sessionId: id, version: parseInt(version, 10) });
|
||||
return { success: true, data: result.plan };
|
||||
});
|
||||
|
||||
// Add a new task to the plan (for runtime adaptation)
|
||||
// ========== Add Plan Task ==========
|
||||
|
||||
app.post('/api/sessions/:id/plan/task', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -453,7 +465,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
const task = ptaResult.data;
|
||||
|
||||
const result = tracker.addPlanTask(task);
|
||||
ctx.broadcast('session:planTaskAdded', { sessionId: id, task: result.task });
|
||||
ctx.broadcast(SseEvent.SessionPlanTaskAdded, { sessionId: id, task: result.task });
|
||||
return { success: true, data: result.task };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse }
|
||||
import { Session } from '../../session.js';
|
||||
import { RespawnController } from '../../respawn-controller.js';
|
||||
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
@@ -23,6 +24,10 @@ export function registerRalphRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
): void {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Ralph Tracker Configuration & Status
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Configure Ralph tracker for a session
|
||||
app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -95,7 +100,7 @@ export function registerRalphRoutes(
|
||||
|
||||
// Persist and broadcast the update
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:ralphLoopUpdate', {
|
||||
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
||||
sessionId: id,
|
||||
state: session.ralphLoopState,
|
||||
});
|
||||
@@ -136,6 +141,10 @@ export function registerRalphRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Fix Plan CRUD (@fix_plan.md generation, import, read/write)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Generate @fix_plan.md content from todos
|
||||
app.get('/api/sessions/:id/fix-plan', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -249,6 +258,10 @@ export function registerRalphRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Ralph Prompt & Loop (prompt write, loop start)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Write Ralph prompt to file in session's working directory
|
||||
// This avoids mux input escaping issues with long multi-line prompts
|
||||
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
||||
@@ -320,7 +333,7 @@ export function registerRalphRoutes(
|
||||
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||||
await writeHooksConfig(casePath);
|
||||
ctx.broadcast('case:created', { name: caseName, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
@@ -441,7 +454,7 @@ export function registerRalphRoutes(
|
||||
name: session.name,
|
||||
reason: 'ralph_loop_start',
|
||||
});
|
||||
ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
// Start interactive mode
|
||||
try {
|
||||
@@ -452,8 +465,8 @@ export function registerRalphRoutes(
|
||||
name: session.name,
|
||||
mode: 'claude',
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id: session.id, mode: 'claude' });
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'claude' });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
} catch (err) {
|
||||
await ctx.cleanupSession(session.id, true, 'ralph_loop_start_error');
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
@@ -475,7 +488,7 @@ export function registerRalphRoutes(
|
||||
controller.start();
|
||||
ctx.saveRespawnConfig(session.id, controller.getConfig());
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('respawn:started', {
|
||||
ctx.broadcast(SseEvent.RespawnStarted, {
|
||||
sessionId: session.id,
|
||||
status: controller.getStatus(),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespawnConfig } from '../../types.js';
|
||||
import { RespawnController, type RespawnConfig } from '../../respawn-controller.js';
|
||||
import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { findSessionOrFail, autoConfigureRalph } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
@@ -25,7 +26,12 @@ export function registerRespawnRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
): void {
|
||||
// Get respawn status for a session
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Respawn Status & Config
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Get Respawn Status ==========
|
||||
|
||||
app.get('/api/sessions/:id/respawn', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
@@ -40,7 +46,8 @@ export function registerRespawnRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// Get respawn config (from running controller or pre-saved)
|
||||
// ========== Get Respawn Config ==========
|
||||
|
||||
app.get('/api/sessions/:id/respawn/config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
@@ -58,7 +65,12 @@ export function registerRespawnRoutes(
|
||||
return { success: true, config: null, active: false };
|
||||
});
|
||||
|
||||
// Start respawn controller for a session
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Respawn Start & Stop
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Start Respawn ==========
|
||||
|
||||
app.post('/api/sessions/:id/respawn/start', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
let body: Partial<RespawnConfig> | undefined;
|
||||
@@ -95,12 +107,13 @@ export function registerRespawnRoutes(
|
||||
ctx.saveRespawnConfig(id, controller.getConfig());
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||||
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return { success: true, status: controller.getStatus() };
|
||||
});
|
||||
|
||||
// Stop respawn controller for a session
|
||||
// ========== Stop Respawn ==========
|
||||
|
||||
app.post('/api/sessions/:id/respawn/stop', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
@@ -130,12 +143,13 @@ export function registerRespawnRoutes(
|
||||
ctx.persistSessionState(session);
|
||||
}
|
||||
|
||||
ctx.broadcast('respawn:stopped', { sessionId: id });
|
||||
ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id });
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Update respawn configuration (works with or without running controller)
|
||||
// ========== Update Respawn Config ==========
|
||||
|
||||
app.put('/api/sessions/:id/respawn/config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Validate respawn config to prevent arbitrary field injection
|
||||
@@ -153,7 +167,7 @@ export function registerRespawnRoutes(
|
||||
controller.updateConfig(config);
|
||||
ctx.saveRespawnConfig(id, controller.getConfig());
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() });
|
||||
ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: controller.getConfig() });
|
||||
return { success: true, config: controller.getConfig() };
|
||||
}
|
||||
|
||||
@@ -186,11 +200,16 @@ export function registerRespawnRoutes(
|
||||
};
|
||||
ctx.mux.updateRespawnConfig(id, merged);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('respawn:configUpdated', { sessionId: id, config: merged });
|
||||
ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: merged });
|
||||
return { success: true, config: merged };
|
||||
});
|
||||
|
||||
// Start interactive session WITH respawn enabled
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Composite Actions (interactive-respawn, enable on existing)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Interactive Respawn (start session + respawn in one call) ==========
|
||||
|
||||
app.post('/api/sessions/:id/interactive-respawn', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true as const, data: {} };
|
||||
@@ -230,8 +249,8 @@ export function registerRespawnRoutes(
|
||||
mode: session.mode,
|
||||
reason: 'interactive_respawn',
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id });
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
// Create and start respawn controller
|
||||
const controller = new RespawnController(session, body?.respawnConfig);
|
||||
@@ -247,7 +266,7 @@ export function registerRespawnRoutes(
|
||||
// Persist full session state with respawn config
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||||
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -261,7 +280,8 @@ export function registerRespawnRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// Enable respawn on an EXISTING interactive session
|
||||
// ========== Enable Respawn on Existing Session ==========
|
||||
|
||||
app.post('/api/sessions/:id/respawn/enable', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true as const, data: {} };
|
||||
@@ -304,7 +324,7 @@ export function registerRespawnRoutes(
|
||||
ctx.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes);
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||||
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type SessionColor,
|
||||
} from '../../types.js';
|
||||
import { Session } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
SessionNameSchema,
|
||||
@@ -54,6 +55,10 @@ export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||
): void {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Auth
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Logout ==========
|
||||
|
||||
app.post('/api/logout', async (req, reply) => {
|
||||
@@ -66,6 +71,10 @@ export function registerSessionRoutes(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session CRUD (list, create, rename, color, delete, detail)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Session Listing ==========
|
||||
|
||||
app.get('/api/sessions', async () => {
|
||||
@@ -146,7 +155,7 @@ export function registerSessionRoutes(
|
||||
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
|
||||
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
|
||||
const lightState = ctx.getSessionStateWithRespawn(session);
|
||||
ctx.broadcast('session:created', lightState);
|
||||
ctx.broadcast(SseEvent.SessionCreated, lightState);
|
||||
return { success: true, session: lightState };
|
||||
});
|
||||
|
||||
@@ -170,7 +179,7 @@ export function registerSessionRoutes(
|
||||
// Also update the mux session name if applicable
|
||||
ctx.mux.updateSessionName(id, session.name);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
return { success: true, name: session.name };
|
||||
});
|
||||
|
||||
@@ -196,7 +205,7 @@ export function registerSessionRoutes(
|
||||
|
||||
session.setColor(body.color as SessionColor);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
return { success: true, color: session.color };
|
||||
});
|
||||
|
||||
@@ -246,6 +255,10 @@ export function registerSessionRoutes(
|
||||
return ctx.getSessionStateWithRespawn(session);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Data (output, ralph state, run summary, active tools)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Get Session Output ==========
|
||||
|
||||
app.get('/api/sessions/:id/output', async (req) => {
|
||||
@@ -328,6 +341,10 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Execution (run prompt, interactive mode, shell mode)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Run Prompt ==========
|
||||
|
||||
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
|
||||
@@ -349,10 +366,10 @@ export function registerSessionRoutes(
|
||||
|
||||
// Run async, don't wait
|
||||
session.runPrompt(prompt).catch((err) => {
|
||||
ctx.broadcast('session:error', { id, error: err.message });
|
||||
ctx.broadcast(SseEvent.SessionError, { id, error: err.message });
|
||||
});
|
||||
|
||||
ctx.broadcast('session:running', { id, prompt });
|
||||
ctx.broadcast(SseEvent.SessionRunning, { id, prompt });
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -391,8 +408,8 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: session.mode,
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id });
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
@@ -422,14 +439,18 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: 'shell',
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id, mode: 'shell' });
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Terminal I/O (input, resize, buffer)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Send Input ==========
|
||||
|
||||
app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
|
||||
@@ -550,6 +571,10 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Settings (auto-clear, auto-compact, image watcher, flicker filter)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Auto-Clear ==========
|
||||
|
||||
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
||||
@@ -567,7 +592,7 @@ export function registerSessionRoutes(
|
||||
|
||||
session.setAutoClear(body.enabled, body.threshold);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -597,7 +622,7 @@ export function registerSessionRoutes(
|
||||
|
||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -661,7 +686,7 @@ export function registerSessionRoutes(
|
||||
|
||||
session.flickerFilterEnabled = body.enabled;
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -671,6 +696,10 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Quick Actions (quick-run, quick-start)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Quick Run ==========
|
||||
|
||||
app.post('/api/run', async (req) => {
|
||||
@@ -717,7 +746,7 @@ export function registerSessionRoutes(
|
||||
reason: 'run_prompt',
|
||||
});
|
||||
|
||||
ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
try {
|
||||
const result = await session.runPrompt(prompt);
|
||||
@@ -786,7 +815,7 @@ export function registerSessionRoutes(
|
||||
await writeHooksConfig(casePath);
|
||||
}
|
||||
|
||||
ctx.broadcast('case:created', { name: caseName, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
@@ -831,7 +860,7 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
reason: 'quick_start',
|
||||
});
|
||||
ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
// Start in the appropriate mode
|
||||
try {
|
||||
@@ -843,7 +872,7 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: 'shell',
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id: session.id, mode: 'shell' });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
|
||||
} else {
|
||||
// Both 'claude' and 'opencode' modes use startInteractive()
|
||||
await session.startInteractive();
|
||||
@@ -853,9 +882,9 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode,
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id: session.id, mode });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode });
|
||||
}
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
// Save lastUsedCase to settings for TUI/web sync
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { subagentWatcher } from '../../subagent-watcher.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { findSessionOrFail, formatUptime, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
||||
@@ -91,6 +92,10 @@ export function registerSystemRoutes(
|
||||
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
|
||||
const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// System Status & Health
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Status ==========
|
||||
|
||||
app.get('/api/status', async () => ctx.getLightState());
|
||||
@@ -120,6 +125,10 @@ export function registerSystemRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Authentication (QR auth, session revocation)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== QR Auth Route ==========
|
||||
|
||||
app.get('/q/:code', async (req, reply) => {
|
||||
@@ -177,7 +186,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// Broadcast auth notification — desktop sees who authenticated
|
||||
ctx.broadcast('tunnel:qrAuthUsed', {
|
||||
ctx.broadcast(SseEvent.TunnelQrAuthUsed, {
|
||||
ip: clientIp,
|
||||
ua: clientUA,
|
||||
timestamp: Date.now(),
|
||||
@@ -206,6 +215,10 @@ export function registerSystemRoutes(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CLI Integrations (OpenCode)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== OpenCode ==========
|
||||
|
||||
app.get('/api/opencode/status', async () => {
|
||||
@@ -216,6 +229,10 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== State & Lifecycle ==========
|
||||
|
||||
app.post('/api/cleanup-state', async () => {
|
||||
@@ -279,6 +296,10 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Configuration & Settings (config, settings, model config, CPU priority)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Config ==========
|
||||
|
||||
app.get('/api/config', async () => {
|
||||
@@ -424,7 +445,7 @@ export function registerSystemRoutes(
|
||||
console.log('Tunnel started via settings change');
|
||||
} else if (tunnelEnabled && ctx.tunnelManager.isRunning() && ctx.tunnelManager.getUrl()) {
|
||||
// Tunnel already running — re-emit so the client gets the URL
|
||||
ctx.broadcast('tunnel:started', { url: ctx.tunnelManager.getUrl() });
|
||||
ctx.broadcast(SseEvent.TunnelStarted, { url: ctx.tunnelManager.getUrl() });
|
||||
console.log('Tunnel already running, re-broadcast URL to client');
|
||||
} else if (!tunnelEnabled && ctx.tunnelManager.isRunning()) {
|
||||
ctx.tunnelManager.stop();
|
||||
@@ -506,7 +527,7 @@ export function registerSystemRoutes(
|
||||
|
||||
session.setNice(body);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -515,6 +536,10 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Subagent Management (window states, parents, monitoring, transcripts)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Subagent Window State Persistence ==========
|
||||
|
||||
app.get('/api/subagent-window-states', async () => {
|
||||
@@ -643,6 +668,10 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: { cleared } };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Screenshots (upload, list, serve)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Screenshots ==========
|
||||
|
||||
app.post('/api/screenshots', async (req, reply) => {
|
||||
|
||||
Reference in New Issue
Block a user