mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 13:09:42 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9d83ec4e3 | ||
|
|
84137cdba4 | ||
|
|
6eb3969816 | ||
|
|
de49437a6f | ||
|
|
6a27639083 | ||
|
|
eb1b38c718 | ||
|
|
ea7b103b47 | ||
|
|
bec8e2f9ee | ||
|
|
2203f3a347 | ||
|
|
867a10d78a |
@@ -1,5 +1,25 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- refactor: codebase cleanup — extract route helpers, eliminate boilerplate, optimize hot paths
|
||||
- Add `parseBody()` helper to route-helpers.ts: validates request body against Zod schema with structured 400 error on failure, replacing 37 identical safeParse + error-check blocks across 10 route files
|
||||
- Add `persistAndBroadcastSession()` helper: combines persist + SessionUpdated broadcast into one call, replacing 5 repeated 2-line pairs
|
||||
- Migrate session-routes.ts to use `findSessionOrFail()` consistently (17 inline session lookups replaced) and `parseBody()` (12 patterns)
|
||||
- Migrate ralph-routes.ts to use `findSessionOrFail()` (9 lookups) and `parseBody()` (4 patterns)
|
||||
- Migrate 8 remaining route files to use `parseBody()` (21 patterns total)
|
||||
- Fix O(n log n) eviction in bash-tool-parser.ts: replace `Array.from().sort()[0]` with O(n) min-scan for oldest active tool
|
||||
- Extract `_debouncedCall()` utility in frontend: replaces 4 manual debounce patterns (7 lines each → 1 line) in app.js, panels-ui.js, ralph-panel.js
|
||||
- Net reduction: 208 lines removed across 16 files
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Visual redesign with glass morphism, refined colors, and polished UI. Optimize history endpoint with buffer reuse and line iterator. Fix Ink frame search window (4KB→64KB) to prevent partial frames. Fix stale terminal data on tab switch via chunkedTerminalWrite cancellation. Improve history prompt extraction with expanded command filtering and tail scan fallback. Align case select group height to match dropdown. Fix no-control-regex lint error for ANSI strip pattern. Add browser-testing-guide to CLAUDE.md references.
|
||||
|
||||
## 0.4.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -52,7 +52,7 @@ When user says "COM":
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
|
||||
**Version**: 0.4.7 (must match `package.json`)
|
||||
**Version**: 0.5.1 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -109,9 +109,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
|
||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules incl. `ws-routes.ts` + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
|
||||
| **Types** | `src/types/index.ts` → 15 domain files | See `@fileoverview` in index.ts |
|
||||
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||
|
||||
@@ -119,7 +119,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Config**: `src/config/` — 9 files. Import from specific files, not barrel.
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks).
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
|
||||
|
||||
### Data Flow
|
||||
|
||||
@@ -226,7 +226,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
|
||||
|
||||
## References
|
||||
|
||||
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`. Agent Teams: `agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
|
||||
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
|
||||
|
||||
## Scripts
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.4.7",
|
||||
"version": "0.5.1",
|
||||
"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",
|
||||
|
||||
+11
-4
@@ -498,10 +498,17 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
|
||||
// Enforce max tools limit
|
||||
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
||||
// Remove oldest tool
|
||||
const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
|
||||
if (oldest) {
|
||||
this._activeTools.delete(oldest[0]);
|
||||
// Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
|
||||
let oldestKey: string | undefined;
|
||||
let oldestTime = Infinity;
|
||||
for (const [key, entry] of this._activeTools) {
|
||||
if (entry.startedAt < oldestTime) {
|
||||
oldestTime = entry.startedAt;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey) {
|
||||
this._activeTools.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-12
@@ -380,11 +380,8 @@ class CodemanApp {
|
||||
this.flickerFilterActive = false;
|
||||
this.flickerFilterTimeout = null;
|
||||
|
||||
// Render debouncing
|
||||
this.renderSessionTabsTimeout = null;
|
||||
this.renderRalphStatePanelTimeout = null;
|
||||
this.renderTaskPanelTimeout = null;
|
||||
this.renderMuxSessionsTimeout = null;
|
||||
// Render debounce timers (managed by _debouncedCall)
|
||||
this._debounceTimers = Object.create(null);
|
||||
|
||||
// System stats polling
|
||||
this.systemStatsInterval = null;
|
||||
@@ -1362,6 +1359,8 @@ class CodemanApp {
|
||||
this.writeFrameScheduled = false;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
|
||||
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
||||
// Preserve local echo overlay text across SSE reconnect — just hide until
|
||||
// terminal buffer reloads and prompt is visible again. _render() re-scans
|
||||
// for the ❯ prompt on every call, so rerender() after buffer load repositions it.
|
||||
@@ -1541,18 +1540,27 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Debounce Utility
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Debounce a method call using a named timer key. */
|
||||
_debouncedCall(timerKey, fn, delayMs = 100) {
|
||||
if (this._debounceTimers[timerKey]) {
|
||||
clearTimeout(this._debounceTimers[timerKey]);
|
||||
}
|
||||
this._debounceTimers[timerKey] = setTimeout(() => {
|
||||
this._debounceTimers[timerKey] = null;
|
||||
fn.call(this);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Tabs
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderSessionTabs() {
|
||||
// Debounce renders at 100ms to prevent excessive DOM updates
|
||||
if (this.renderSessionTabsTimeout) {
|
||||
clearTimeout(this.renderSessionTabsTimeout);
|
||||
}
|
||||
this.renderSessionTabsTimeout = setTimeout(() => {
|
||||
this._renderSessionTabsImmediate();
|
||||
}, 100);
|
||||
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
|
||||
}
|
||||
|
||||
/** Toggle .active class on tabs immediately (no debounce). Used by selectSession(). */
|
||||
@@ -2020,6 +2028,10 @@ class CodemanApp {
|
||||
this.writeFrameScheduled = false;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
// Abort any in-flight chunkedTerminalWrite from the previous session.
|
||||
// Without this, old rAF-scheduled chunks continue writing stale data
|
||||
// into the terminal, interleaving with the new session's buffer.
|
||||
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
||||
// End any in-flight IME composition.
|
||||
// iOS Safari keeps autocorrect composing; switching tabs without ending it
|
||||
// leaves xterm's _compositionHelper._isComposing stuck true, which blocks
|
||||
@@ -2136,6 +2148,11 @@ class CodemanApp {
|
||||
this._isLoadingBuffer = true;
|
||||
this._loadBufferQueue = [];
|
||||
try {
|
||||
// Fit terminal to container BEFORE writing any buffer data.
|
||||
// If the browser was resized while viewing another session, the terminal
|
||||
// canvas may be at stale dimensions — content would render at wrong width.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
|
||||
// Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls.
|
||||
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread
|
||||
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously.
|
||||
|
||||
@@ -30,13 +30,13 @@
|
||||
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#0a0a0a}
|
||||
.skeleton-header{height:40px;background:#111;border-bottom:1px solid #1a1a2e;display:flex;align-items:center;padding:0 12px}
|
||||
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#09090b}
|
||||
.skeleton-header{height:40px;background:rgba(19,19,22,0.85);border-bottom:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;padding:0 12px}
|
||||
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:700;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
|
||||
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
|
||||
.skeleton-tab{width:80px;height:24px;background:#1a1a2e;border-radius:4px}
|
||||
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
|
||||
.skeleton-terminal{flex:1;background:#0d0d0d}
|
||||
.skeleton-toolbar{height:32px;background:#111;border-top:1px solid #1a1a2e}
|
||||
.skeleton-toolbar{height:42px;background:rgba(19,19,22,0.85);border-top:1px solid rgba(255,255,255,0.06)}
|
||||
.app-loaded .loading-skeleton{display:none}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -613,13 +613,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
renderTaskPanel() {
|
||||
// Debounce renders at 100ms to prevent excessive DOM updates
|
||||
if (this.renderTaskPanelTimeout) {
|
||||
clearTimeout(this.renderTaskPanelTimeout);
|
||||
}
|
||||
this.renderTaskPanelTimeout = setTimeout(() => {
|
||||
this._renderTaskPanelImmediate();
|
||||
}, 100);
|
||||
this._debouncedCall('taskPanel', this._renderTaskPanelImmediate);
|
||||
},
|
||||
|
||||
_renderTaskPanelImmediate() {
|
||||
@@ -2974,13 +2968,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
|
||||
renderMuxSessions() {
|
||||
// Debounce renders at 100ms to prevent excessive DOM updates
|
||||
if (this.renderMuxSessionsTimeout) {
|
||||
clearTimeout(this.renderMuxSessionsTimeout);
|
||||
}
|
||||
this.renderMuxSessionsTimeout = setTimeout(() => {
|
||||
this._renderMuxSessionsImmediate();
|
||||
}, 100);
|
||||
this._debouncedCall('muxSessions', this._renderMuxSessionsImmediate);
|
||||
},
|
||||
|
||||
_renderMuxSessionsImmediate() {
|
||||
|
||||
@@ -471,13 +471,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
renderRalphStatePanel() {
|
||||
// Debounce renders at 50ms to prevent excessive DOM updates
|
||||
if (this.renderRalphStatePanelTimeout) {
|
||||
clearTimeout(this.renderRalphStatePanelTimeout);
|
||||
}
|
||||
this.renderRalphStatePanelTimeout = setTimeout(() => {
|
||||
this._renderRalphStatePanelImmediate();
|
||||
}, 50);
|
||||
this._debouncedCall('ralphStatePanel', this._renderRalphStatePanelImmediate, 50);
|
||||
},
|
||||
|
||||
_renderRalphStatePanelImmediate() {
|
||||
|
||||
+326
-236
File diff suppressed because it is too large
Load Diff
@@ -219,6 +219,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Welcome message
|
||||
this.showWelcome();
|
||||
|
||||
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
|
||||
this._chunkedWriteGen = 0;
|
||||
|
||||
// Handle resize with throttling for performance
|
||||
this._resizeTimeout = null;
|
||||
this._lastResizeDims = null;
|
||||
@@ -1033,6 +1036,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
* @returns {Promise<void>} - Resolves when all chunks written
|
||||
*/
|
||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
|
||||
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
|
||||
// older writes abort instead of continuing to push stale data into the terminal.
|
||||
const writeGen = ++this._chunkedWriteGen;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
this._finishBufferLoad();
|
||||
@@ -1049,7 +1056,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
||||
|
||||
const finish = () => {
|
||||
this._finishBufferLoad();
|
||||
// Only finish if we're still the active write — a newer write owns buffer load state
|
||||
if (this._chunkedWriteGen === writeGen) {
|
||||
this._finishBufferLoad();
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
@@ -1067,6 +1077,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
const _chunkStart = performance.now();
|
||||
let _chunkCount = 0;
|
||||
const writeChunk = () => {
|
||||
// Abort if a newer chunked write started (user switched tabs)
|
||||
if (this._chunkedWriteGen !== writeGen) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (offset >= cleanBuffer.length) {
|
||||
const _totalMs = performance.now() - _chunkStart;
|
||||
console.log(`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`);
|
||||
@@ -1240,6 +1256,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sendResize(sessionId) {
|
||||
// Fit terminal to container before reading dimensions — ensures local
|
||||
// terminal size matches what we report to the server PTY.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (!dims) return;
|
||||
// Fast path: WebSocket resize
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { z } from 'zod';
|
||||
import { Session } from '../session.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../types.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
@@ -50,6 +51,31 @@ export function findSessionOrFail(ctx: SessionPort, sessionId: string): Session
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a request body against a Zod schema, or throw a structured 400 error.
|
||||
* Replaces the repeated pattern: `const r = Schema.safeParse(body); if (!r.success) return createErrorResponse(...)`.
|
||||
*/
|
||||
export function parseBody<T>(schema: z.ZodType<T>, body: unknown, errorMessage?: string): T {
|
||||
const result = schema.safeParse(body);
|
||||
if (!result.success) {
|
||||
const msg = errorMessage ?? result.error.issues[0]?.message ?? 'Validation failed';
|
||||
throw Object.assign(new Error(msg), {
|
||||
statusCode: 400,
|
||||
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, msg),
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist session state and broadcast a SessionUpdated event.
|
||||
* Replaces the repeated two-line pattern across route handlers.
|
||||
*/
|
||||
export function persistAndBroadcastSession(ctx: SessionPort & EventPort, session: Session): void {
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats uptime in seconds to a human-readable string (e.g., "1d 2h 30m 15s").
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.
|
||||
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import { CASES_DIR, validatePathWithinBase, parseBody } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
|
||||
@@ -83,11 +83,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
});
|
||||
|
||||
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const result = CreateCaseSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { name, description } = result.data;
|
||||
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
||||
|
||||
const casePath = validatePathWithinBase(name, CASES_DIR);
|
||||
if (!casePath) {
|
||||
@@ -120,11 +116,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
// Link an existing folder as a case
|
||||
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const lcResult = LinkCaseSchema.safeParse(req.body);
|
||||
if (!lcResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { name, path: folderPath } = lcResult.data;
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Expand ~ to home directory
|
||||
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
|
||||
import { sanitizeHookData } from '../route-helpers.js';
|
||||
import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
export function registerHookEventRoutes(
|
||||
@@ -15,11 +15,7 @@ export function registerHookEventRoutes(
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
): void {
|
||||
app.post('/api/hook-event', async (req) => {
|
||||
const result = HookEventSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { event, sessionId, data } = result.data;
|
||||
const { event, sessionId, data } = parseBody(HookEventSchema, req.body);
|
||||
if (!ctx.sessions.has(sessionId)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, OrchestratorPort } from '../ports/index.js';
|
||||
|
||||
@@ -85,12 +86,7 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/start', async (req) => {
|
||||
const parsed = OrchestratorStartSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
|
||||
const { goal, config } = parsed.data;
|
||||
const { goal, config } = parseBody(OrchestratorStartSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Initialize loop if needed
|
||||
let loop = ctx.orchestratorLoop;
|
||||
@@ -141,13 +137,10 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
app.post('/api/orchestrator/reject', async (req) => {
|
||||
const loop = getLoop();
|
||||
|
||||
const parsed = OrchestratorRejectSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Feedback is required');
|
||||
}
|
||||
const { feedback } = parseBody(OrchestratorRejectSchema, req.body, 'Feedback is required');
|
||||
|
||||
try {
|
||||
loop.reject(parsed.data.feedback).catch((err) => {
|
||||
loop.reject(feedback).catch((err) => {
|
||||
console.error('[Orchestrator Route] Reject failed:', getErrorMessage(err));
|
||||
});
|
||||
return { ok: true, state: loop.state };
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
PlanTaskUpdateSchema,
|
||||
PlanTaskAddSchema,
|
||||
} from '../schemas.js';
|
||||
import { findSessionOrFail, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import { findSessionOrFail, parseBody, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
@@ -29,11 +29,11 @@ export function registerPlanRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// ========== Generate Plan (Simple) ==========
|
||||
|
||||
app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
|
||||
const gpResult = GeneratePlanSchema.safeParse(req.body);
|
||||
if (!gpResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { taskDescription, detailLevel = 'standard' } = gpResult.data;
|
||||
const { taskDescription, detailLevel = 'standard' } = parseBody(
|
||||
GeneratePlanSchema,
|
||||
req.body,
|
||||
'Invalid request body'
|
||||
);
|
||||
|
||||
// Build sophisticated prompt based on Ralph Wiggum methodology
|
||||
const detailConfig = {
|
||||
@@ -223,11 +223,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// ========== Generate Plan (Detailed Orchestration) ==========
|
||||
|
||||
app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => {
|
||||
const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body);
|
||||
if (!gpdResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { taskDescription, caseName } = gpdResult.data;
|
||||
const { taskDescription, caseName } = parseBody(GeneratePlanDetailedSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Determine output directory for saving wizard results
|
||||
let outputDir: string | undefined;
|
||||
@@ -327,11 +323,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// ========== Cancel Plan Generation ==========
|
||||
|
||||
app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => {
|
||||
const cpResult = CancelPlanSchema.safeParse(req.body);
|
||||
if (!cpResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { orchestratorId } = cpResult.data;
|
||||
const { orchestratorId } = parseBody(CancelPlanSchema, req.body, 'Invalid request body');
|
||||
|
||||
// If specific orchestrator ID provided, cancel just that one
|
||||
if (orchestratorId) {
|
||||
@@ -374,11 +366,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
||||
}
|
||||
|
||||
const ptuResult = PlanTaskUpdateSchema.safeParse(req.body);
|
||||
if (!ptuResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const update = ptuResult.data as {
|
||||
const update = parseBody(PlanTaskUpdateSchema, req.body, 'Invalid request body') as {
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked';
|
||||
error?: string;
|
||||
incrementAttempts?: boolean;
|
||||
@@ -454,11 +442,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
||||
}
|
||||
|
||||
const ptaResult = PlanTaskAddSchema.safeParse(req.body);
|
||||
if (!ptaResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const task = ptaResult.data;
|
||||
const task = parseBody(PlanTaskAddSchema, req.body, 'Invalid request body');
|
||||
|
||||
const result = tracker.addPlanTask(task);
|
||||
ctx.broadcast(SseEvent.SessionPlanTaskAdded, { sessionId: id, task: result.task });
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { PushSubscribeSchema, PushPreferencesUpdateSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import type { InfraPort } from '../ports/index.js';
|
||||
|
||||
export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
@@ -15,11 +16,7 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
});
|
||||
|
||||
app.post('/api/push/subscribe', async (req) => {
|
||||
const result = PushSubscribeSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { endpoint, keys, userAgent, pushPreferences } = result.data;
|
||||
const { endpoint, keys, userAgent, pushPreferences } = parseBody(PushSubscribeSchema, req.body);
|
||||
const record = ctx.pushStore.addSubscription({
|
||||
id: uuidv4(),
|
||||
endpoint,
|
||||
@@ -33,11 +30,8 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
|
||||
app.put('/api/push/subscribe/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = PushPreferencesUpdateSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const updated = ctx.pushStore.updatePreferences(id, result.data.pushPreferences);
|
||||
const { pushPreferences } = parseBody(PushPreferencesUpdateSchema, req.body);
|
||||
const updated = ctx.pushStore.updatePreferences(id, pushPreferences);
|
||||
if (!updated) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
@@ -31,22 +31,18 @@ export function registerRalphRoutes(
|
||||
// Configure Ralph tracker for a session
|
||||
app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const ralphResult = RalphConfigSchema.safeParse(req.body);
|
||||
if (!ralphResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data as {
|
||||
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = parseBody(
|
||||
RalphConfigSchema,
|
||||
req.body,
|
||||
'Invalid request body'
|
||||
) as {
|
||||
enabled?: boolean;
|
||||
completionPhrase?: string;
|
||||
maxIterations?: number;
|
||||
reset?: boolean | 'full';
|
||||
disableAutoEnable?: boolean;
|
||||
};
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// Ralph tracker is not supported for opencode sessions
|
||||
if (session.mode === 'opencode') {
|
||||
@@ -111,11 +107,7 @@ export function registerRalphRoutes(
|
||||
// Reset circuit breaker for Ralph tracker
|
||||
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.ralphTracker.resetCircuitBreaker();
|
||||
return { success: true };
|
||||
@@ -124,11 +116,7 @@ export function registerRalphRoutes(
|
||||
// Get Ralph status block and circuit breaker state
|
||||
app.get('/api/sessions/:id/ralph-status', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -148,11 +136,7 @@ export function registerRalphRoutes(
|
||||
// Generate @fix_plan.md content from todos
|
||||
app.get('/api/sessions/:id/fix-plan', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const content = session.ralphTracker.generateFixPlanMarkdown();
|
||||
return {
|
||||
@@ -167,16 +151,8 @@ export function registerRalphRoutes(
|
||||
// Import todos from @fix_plan.md content
|
||||
app.post('/api/sessions/:id/fix-plan/import', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const importResult = FixPlanImportSchema.safeParse(req.body);
|
||||
if (!importResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { content } = importResult.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
||||
ctx.persistSessionState(session);
|
||||
@@ -193,11 +169,7 @@ export function registerRalphRoutes(
|
||||
// Write @fix_plan.md to session's working directory
|
||||
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -224,11 +196,7 @@ export function registerRalphRoutes(
|
||||
// Read @fix_plan.md from session's working directory and import
|
||||
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -266,16 +234,8 @@ export function registerRalphRoutes(
|
||||
// This avoids mux input escaping issues with long multi-line prompts
|
||||
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const promptResult = RalphPromptWriteSchema.safeParse(req.body);
|
||||
if (!promptResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { content } = promptResult.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -308,11 +268,10 @@ export function registerRalphRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
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 { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = parseBody(
|
||||
RalphLoopStartSchema,
|
||||
req.body
|
||||
);
|
||||
|
||||
const casePath = join(CASES_DIR, caseName);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespa
|
||||
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 { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
@@ -84,11 +84,7 @@ export function registerRespawnRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
let body: Partial<RespawnConfig> | undefined;
|
||||
if (req.body) {
|
||||
const result = RespawnConfigSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config');
|
||||
}
|
||||
body = result.data as Partial<RespawnConfig>;
|
||||
body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
@@ -162,11 +158,7 @@ export function registerRespawnRoutes(
|
||||
app.put('/api/sessions/:id/respawn/config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Validate respawn config to prevent arbitrary field injection
|
||||
const parseResult = RespawnConfigSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`);
|
||||
}
|
||||
const config = parseResult.data as Partial<RespawnConfig>;
|
||||
const config = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify';
|
||||
import { statSync } from 'node:fs';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||
import { ScheduledRunSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js';
|
||||
|
||||
export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void {
|
||||
@@ -15,11 +16,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
});
|
||||
|
||||
app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse<never>> => {
|
||||
const srResult = ScheduledRunSchema.safeParse(req.body);
|
||||
if (!srResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { prompt, workingDir, durationMinutes } = srResult.data;
|
||||
const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
if (workingDir) {
|
||||
|
||||
+126
-185
@@ -32,7 +32,15 @@ import {
|
||||
QuickRunSchema,
|
||||
QuickStartSchema,
|
||||
} from '../schemas.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, validatePathWithinBase } from '../route-helpers.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
CASES_DIR,
|
||||
findSessionOrFail,
|
||||
parseBody,
|
||||
persistAndBroadcastSession,
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
@@ -73,15 +81,28 @@ function stripInkRedrawBloat(buffer: string): string {
|
||||
// If the redraw section is small (<16KB), not worth stripping
|
||||
if (redrawPart.length < 16384) return buffer;
|
||||
|
||||
// Keep only the last 4KB of redraw frames — this preserves the final visual state
|
||||
// (spinner position, status bar text, token count, etc.)
|
||||
const tail = redrawPart.slice(-4096);
|
||||
// Avoid starting mid-escape: find first complete frame boundary
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const frameStart = tail.search(/\x1b\(B\x1b\[m|\x1b\[\d+d|\x1b\[\d+;\d+H/);
|
||||
const cleanTail = frameStart > 0 ? tail.slice(frameStart) : tail;
|
||||
// Find the last complete Ink frame by searching for where the VPA row
|
||||
// number drops (cursor jumps back to viewport top for a new render cycle).
|
||||
// Search the last 64KB — a single Ink frame with response content can be
|
||||
// 10-20KB, so 4KB was too small and caused partial frames (blank gap).
|
||||
const searchLen = Math.min(redrawPart.length, 65536);
|
||||
const searchWindow = redrawPart.slice(-searchLen);
|
||||
|
||||
return contentPart + cleanTail;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const vpaRe = /\x1b\[(\d+)d/g;
|
||||
let lastFrameStart = 0;
|
||||
let prevRow = -1;
|
||||
let match;
|
||||
while ((match = vpaRe.exec(searchWindow)) !== null) {
|
||||
const row = parseInt(match[1], 10);
|
||||
// Row number dropped significantly — Ink started a new frame
|
||||
if (prevRow > 0 && row < prevRow - 5) {
|
||||
lastFrameStart = match.index;
|
||||
}
|
||||
prevRow = row;
|
||||
}
|
||||
|
||||
return contentPart + searchWindow.slice(lastFrameStart);
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
@@ -125,11 +146,7 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const result = CreateSessionSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const body = result.data;
|
||||
const body = parseBody(CreateSessionSchema, req.body);
|
||||
const workingDir = body.workingDir || process.cwd();
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
@@ -197,23 +214,14 @@ export function registerSessionRoutes(
|
||||
|
||||
app.put('/api/sessions/:id/name', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = SessionNameSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = result.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const body = parseBody(SessionNameSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||||
session.name = name;
|
||||
// Also update the mux session name if applicable
|
||||
ctx.mux.updateSessionName(id, session.name);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
return { success: true, name: session.name };
|
||||
});
|
||||
|
||||
@@ -221,16 +229,8 @@ export function registerSessionRoutes(
|
||||
|
||||
app.put('/api/sessions/:id/color', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = SessionColorSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = result.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const body = parseBody(SessionColorSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
||||
if (!validColors.includes(body.color)) {
|
||||
@@ -238,8 +238,7 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
session.setColor(body.color as SessionColor);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
return { success: true, color: session.color };
|
||||
});
|
||||
|
||||
@@ -278,11 +277,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
|
||||
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
|
||||
@@ -297,11 +292,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/output', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -317,11 +308,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/ralph-state', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -337,11 +324,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/run-summary', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const tracker = ctx.runSummaryTrackers.get(id);
|
||||
if (!tracker) {
|
||||
@@ -361,11 +344,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/active-tools', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -383,16 +362,8 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = RunPromptSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { prompt } = result.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const { prompt } = parseBody(RunPromptSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -411,11 +382,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -455,11 +422,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -489,16 +452,8 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = SessionInputWithLimitSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { input, useMux } = result.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const inputStr = String(input);
|
||||
if (inputStr.length > MAX_INPUT_LENGTH) {
|
||||
@@ -534,16 +489,8 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = ResizeSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { cols, rows } = result.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const { cols, rows } = parseBody(ResizeSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.resize(cols, rows);
|
||||
return { success: true };
|
||||
@@ -556,11 +503,7 @@ export function registerSessionRoutes(
|
||||
app.get('/api/sessions/:id/terminal', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { tail?: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||
const fullSize = session.terminalBufferLength;
|
||||
@@ -619,20 +562,11 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const acResult = AutoClearSchema.safeParse(req.body);
|
||||
if (!acResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = acResult.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.setAutoClear(body.enabled, body.threshold);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -649,20 +583,11 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const compactResult = AutoCompactSchema.safeParse(req.body);
|
||||
if (!compactResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = compactResult.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -680,16 +605,8 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const iwResult = ImageWatcherSchema.safeParse(req.body);
|
||||
if (!iwResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = iwResult.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (body.enabled) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
@@ -713,20 +630,11 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const ffResult = FlickerFilterSchema.safeParse(req.body);
|
||||
if (!ffResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = ffResult.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.flickerFilterEnabled = body.enabled;
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -751,11 +659,7 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const qrResult = QuickRunSchema.safeParse(req.body);
|
||||
if (!qrResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { prompt, workingDir } = qrResult.data;
|
||||
const { prompt, workingDir } = parseBody(QuickRunSchema, req.body, 'Invalid request body');
|
||||
|
||||
if (!prompt.trim()) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
||||
@@ -811,11 +715,7 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const result = QuickStartSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data;
|
||||
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (mode === 'opencode') {
|
||||
@@ -965,7 +865,12 @@ export function registerSessionRoutes(
|
||||
/** Extract the text of the first user message from a JSONL transcript head. */
|
||||
function extractFirstUserPrompt(head: string): string | undefined {
|
||||
const MAX_PROMPT_LEN = 120;
|
||||
for (const line of head.split('\n')) {
|
||||
// Iterate lines without allocating a full split array
|
||||
let start = 0;
|
||||
while (start < head.length) {
|
||||
const end = head.indexOf('\n', start);
|
||||
const line = end === -1 ? head.slice(start) : head.slice(start, end);
|
||||
start = end === -1 ? head.length : end + 1;
|
||||
if (!line.includes('"type":"user"')) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
@@ -979,14 +884,25 @@ export function registerSessionRoutes(
|
||||
if (textBlock) text = textBlock.text;
|
||||
}
|
||||
if (!text) continue;
|
||||
// Strip XML-like system/command tags that Claude Code injects into transcripts
|
||||
// Strip XML-like system/command tags and ANSI escapes from transcripts
|
||||
text = text
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
if (!text) continue;
|
||||
// Skip system-injected messages and slash command artifacts (not real user prompts)
|
||||
if (/^(Caveat:|init\b|clear\b|\/\w+ \w+$|You are a )/i.test(text)) continue;
|
||||
// Skip system-injected messages, slash command artifacts, and expanded skill prompts
|
||||
if (
|
||||
/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(text) ||
|
||||
/^(Please )?(analyze|review) this codebase/i.test(text) ||
|
||||
/^(Read|Implement the following) .+, then (search|list|check) /i.test(text) ||
|
||||
/^\d+ vulnerabilit/i.test(text) ||
|
||||
/\btoolu_/.test(text) ||
|
||||
/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(text) ||
|
||||
/\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(text) ||
|
||||
text.length < 8
|
||||
)
|
||||
continue;
|
||||
return text.length > MAX_PROMPT_LEN ? text.slice(0, MAX_PROMPT_LEN) + '…' : text;
|
||||
} catch {
|
||||
// Malformed line — skip
|
||||
@@ -995,6 +911,37 @@ export function registerSessionRoutes(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Read the first 16KB of a file for content sniffing. */
|
||||
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
|
||||
try {
|
||||
const fd = await fs.open(path, 'r');
|
||||
const { bytesRead } = await fd.read(buf, 0, buf.length, 0);
|
||||
await fd.close();
|
||||
return buf.toString('utf8', 0, bytesRead);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the last `buf.length` bytes of a file (for tail-scanning user prompts). */
|
||||
async function readFileTail(path: string, buf: Buffer, fileSize: number): Promise<string | null> {
|
||||
try {
|
||||
const fd = await fs.open(path, 'r');
|
||||
const offset = Math.max(0, fileSize - buf.length);
|
||||
const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
|
||||
await fd.close();
|
||||
const text = buf.toString('utf8', 0, bytesRead);
|
||||
// Skip first partial line when we didn't read from the start
|
||||
if (offset > 0) {
|
||||
const nl = text.indexOf('\n');
|
||||
return nl >= 0 ? text.slice(nl + 1) : null;
|
||||
}
|
||||
return text;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/history/sessions', async () => {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const results: Array<{
|
||||
@@ -1005,6 +952,7 @@ export function registerSessionRoutes(
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
}> = [];
|
||||
const headBuf = Buffer.alloc(16384);
|
||||
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
@@ -1042,20 +990,9 @@ export function registerSessionRoutes(
|
||||
// no "user"/"assistant" messages and will fail claude --resume.
|
||||
// Read first 16KB to check content and extract first user prompt.
|
||||
let firstPrompt: string | undefined;
|
||||
const readHead = async (): Promise<string | null> => {
|
||||
try {
|
||||
const fd = await fs.open(filePath, 'r');
|
||||
const buf = Buffer.alloc(16384);
|
||||
const { bytesRead } = await fd.read(buf, 0, 16384, 0);
|
||||
await fd.close();
|
||||
return buf.toString('utf8', 0, bytesRead);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const head = await readFileHead(filePath, headBuf);
|
||||
|
||||
if (fileStat.size < 50000) {
|
||||
const head = await readHead();
|
||||
if (
|
||||
!head ||
|
||||
(!head.includes('"type":"user"') &&
|
||||
@@ -1064,11 +1001,15 @@ export function registerSessionRoutes(
|
||||
) {
|
||||
continue; // No conversation content — skip
|
||||
}
|
||||
firstPrompt = extractFirstUserPrompt(head);
|
||||
} else {
|
||||
// Large files are almost certainly real conversations; still read head for prompt
|
||||
const head = await readHead();
|
||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||
}
|
||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||
|
||||
// If head scan found no usable prompt (e.g. session started with /init),
|
||||
// try reading the tail for a recent user message.
|
||||
if (!firstPrompt && fileStat.size > 65536) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
const tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||
}
|
||||
|
||||
results.push({
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
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 { findSessionOrFail, formatUptime, parseBody, 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';
|
||||
@@ -324,11 +324,8 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
app.put('/api/config', async (req) => {
|
||||
const parseResult = ConfigUpdateSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`);
|
||||
}
|
||||
ctx.store.setConfig(parseResult.data as Partial<ReturnType<typeof ctx.store.getConfig>>);
|
||||
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
|
||||
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
|
||||
return { success: true, config: ctx.store.getConfig() };
|
||||
});
|
||||
|
||||
@@ -408,11 +405,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
app.put('/api/settings', async (req) => {
|
||||
const settingsResult = SettingsUpdateSchema.safeParse(req.body);
|
||||
if (!settingsResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings');
|
||||
}
|
||||
const settings = settingsResult.data as Record<string, unknown>;
|
||||
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
const dir = dirname(SETTINGS_PATH);
|
||||
@@ -492,11 +485,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
app.put('/api/execution/model-config', async (req) => {
|
||||
const mcResult = ModelConfigUpdateSchema.safeParse(req.body);
|
||||
if (!mcResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config');
|
||||
}
|
||||
const modelConfig = mcResult.data as Record<string, unknown>;
|
||||
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
@@ -536,11 +525,7 @@ export function registerSystemRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const clResult = CpuLimitSchema.safeParse(req.body);
|
||||
if (!clResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = clResult.data as Partial<NiceConfig>;
|
||||
const body = parseBody(CpuLimitSchema, req.body, 'Invalid request body') as Partial<NiceConfig>;
|
||||
|
||||
session.setNice(body);
|
||||
ctx.persistSessionState(session);
|
||||
@@ -572,11 +557,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
app.put('/api/subagent-window-states', async (req) => {
|
||||
const swResult = SubagentWindowStatesSchema.safeParse(req.body);
|
||||
if (!swResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states');
|
||||
}
|
||||
const states = swResult.data as Record<string, unknown>;
|
||||
const states = parseBody(SubagentWindowStatesSchema, req.body, 'Invalid window states') as Record<string, unknown>;
|
||||
try {
|
||||
const dir = dirname(windowStatesPath);
|
||||
if (!existsSync(dir)) {
|
||||
@@ -604,11 +585,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
app.put('/api/subagent-parents', async (req) => {
|
||||
const spResult = SubagentParentMapSchema.safeParse(req.body);
|
||||
if (!spResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map');
|
||||
}
|
||||
const parentMap = spResult.data;
|
||||
const parentMap = parseBody(SubagentParentMapSchema, req.body, 'Invalid parent map');
|
||||
try {
|
||||
const dir = dirname(parentMapPath);
|
||||
if (!existsSync(dir)) {
|
||||
|
||||
Reference in New Issue
Block a user