mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-30 04:39:33 +01:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9d83ec4e3 | ||
|
|
84137cdba4 | ||
|
|
6eb3969816 | ||
|
|
de49437a6f | ||
|
|
6a27639083 | ||
|
|
eb1b38c718 | ||
|
|
ea7b103b47 | ||
|
|
bec8e2f9ee | ||
|
|
2203f3a347 | ||
|
|
867a10d78a |
No files matched your search
@@ -1,5 +1,25 @@
|
|||||||
# aicodeman
|
# 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
|
## 0.4.7
|
||||||
|
|
||||||
### Patch Changes
|
### 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`
|
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`
|
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
|
## 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` | |
|
| **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` | |
|
| **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` | |
|
| **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` | |
|
| **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.
|
★ = 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.
|
**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
|
### Data Flow
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
|
|||||||
|
|
||||||
## References
|
## 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
|
## Scripts
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"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",
|
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+11
-4
@@ -498,10 +498,17 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
|
|
||||||
// Enforce max tools limit
|
// Enforce max tools limit
|
||||||
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
||||||
// Remove oldest tool
|
// Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
|
||||||
const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
|
let oldestKey: string | undefined;
|
||||||
if (oldest) {
|
let oldestTime = Infinity;
|
||||||
this._activeTools.delete(oldest[0]);
|
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.flickerFilterActive = false;
|
||||||
this.flickerFilterTimeout = null;
|
this.flickerFilterTimeout = null;
|
||||||
|
|
||||||
// Render debouncing
|
// Render debounce timers (managed by _debouncedCall)
|
||||||
this.renderSessionTabsTimeout = null;
|
this._debounceTimers = Object.create(null);
|
||||||
this.renderRalphStatePanelTimeout = null;
|
|
||||||
this.renderTaskPanelTimeout = null;
|
|
||||||
this.renderMuxSessionsTimeout = null;
|
|
||||||
|
|
||||||
// System stats polling
|
// System stats polling
|
||||||
this.systemStatsInterval = null;
|
this.systemStatsInterval = null;
|
||||||
@@ -1362,6 +1359,8 @@ class CodemanApp {
|
|||||||
this.writeFrameScheduled = false;
|
this.writeFrameScheduled = false;
|
||||||
this._isLoadingBuffer = false;
|
this._isLoadingBuffer = false;
|
||||||
this._loadBufferQueue = null;
|
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
|
// Preserve local echo overlay text across SSE reconnect — just hide until
|
||||||
// terminal buffer reloads and prompt is visible again. _render() re-scans
|
// terminal buffer reloads and prompt is visible again. _render() re-scans
|
||||||
// for the ❯ prompt on every call, so rerender() after buffer load repositions it.
|
// 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
|
// Session Tabs
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
renderSessionTabs() {
|
renderSessionTabs() {
|
||||||
// Debounce renders at 100ms to prevent excessive DOM updates
|
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
|
||||||
if (this.renderSessionTabsTimeout) {
|
|
||||||
clearTimeout(this.renderSessionTabsTimeout);
|
|
||||||
}
|
|
||||||
this.renderSessionTabsTimeout = setTimeout(() => {
|
|
||||||
this._renderSessionTabsImmediate();
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggle .active class on tabs immediately (no debounce). Used by selectSession(). */
|
/** Toggle .active class on tabs immediately (no debounce). Used by selectSession(). */
|
||||||
@@ -2020,6 +2028,10 @@ class CodemanApp {
|
|||||||
this.writeFrameScheduled = false;
|
this.writeFrameScheduled = false;
|
||||||
this._isLoadingBuffer = false;
|
this._isLoadingBuffer = false;
|
||||||
this._loadBufferQueue = null;
|
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.
|
// End any in-flight IME composition.
|
||||||
// iOS Safari keeps autocorrect composing; switching tabs without ending it
|
// iOS Safari keeps autocorrect composing; switching tabs without ending it
|
||||||
// leaves xterm's _compositionHelper._isComposing stuck true, which blocks
|
// leaves xterm's _compositionHelper._isComposing stuck true, which blocks
|
||||||
@@ -2136,6 +2148,11 @@ class CodemanApp {
|
|||||||
this._isLoadingBuffer = true;
|
this._isLoadingBuffer = true;
|
||||||
this._loadBufferQueue = [];
|
this._loadBufferQueue = [];
|
||||||
try {
|
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.
|
// 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
|
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread
|
||||||
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously.
|
// 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>
|
<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) -->
|
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||||
<style>
|
<style>
|
||||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#0a0a0a}
|
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#09090b}
|
||||||
.skeleton-header{height:40px;background:#111;border-bottom:1px solid #1a1a2e;display:flex;align-items:center;padding:0 12px}
|
.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:600;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
|
.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-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-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}
|
.app-loaded .loading-skeleton{display:none}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -613,13 +613,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderTaskPanel() {
|
renderTaskPanel() {
|
||||||
// Debounce renders at 100ms to prevent excessive DOM updates
|
this._debouncedCall('taskPanel', this._renderTaskPanelImmediate);
|
||||||
if (this.renderTaskPanelTimeout) {
|
|
||||||
clearTimeout(this.renderTaskPanelTimeout);
|
|
||||||
}
|
|
||||||
this.renderTaskPanelTimeout = setTimeout(() => {
|
|
||||||
this._renderTaskPanelImmediate();
|
|
||||||
}, 100);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_renderTaskPanelImmediate() {
|
_renderTaskPanelImmediate() {
|
||||||
@@ -2974,13 +2968,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
|
|
||||||
renderMuxSessions() {
|
renderMuxSessions() {
|
||||||
// Debounce renders at 100ms to prevent excessive DOM updates
|
this._debouncedCall('muxSessions', this._renderMuxSessionsImmediate);
|
||||||
if (this.renderMuxSessionsTimeout) {
|
|
||||||
clearTimeout(this.renderMuxSessionsTimeout);
|
|
||||||
}
|
|
||||||
this.renderMuxSessionsTimeout = setTimeout(() => {
|
|
||||||
this._renderMuxSessionsImmediate();
|
|
||||||
}, 100);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_renderMuxSessionsImmediate() {
|
_renderMuxSessionsImmediate() {
|
||||||
|
|||||||
@@ -471,13 +471,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderRalphStatePanel() {
|
renderRalphStatePanel() {
|
||||||
// Debounce renders at 50ms to prevent excessive DOM updates
|
this._debouncedCall('ralphStatePanel', this._renderRalphStatePanelImmediate, 50);
|
||||||
if (this.renderRalphStatePanelTimeout) {
|
|
||||||
clearTimeout(this.renderRalphStatePanelTimeout);
|
|
||||||
}
|
|
||||||
this.renderRalphStatePanelTimeout = setTimeout(() => {
|
|
||||||
this._renderRalphStatePanelImmediate();
|
|
||||||
}, 50);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_renderRalphStatePanelImmediate() {
|
_renderRalphStatePanelImmediate() {
|
||||||
|
|||||||
+326
-236
File diff suppressed because it is too large.
Load diff
@@ -219,6 +219,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Welcome message
|
// Welcome message
|
||||||
this.showWelcome();
|
this.showWelcome();
|
||||||
|
|
||||||
|
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
|
||||||
|
this._chunkedWriteGen = 0;
|
||||||
|
|
||||||
// Handle resize with throttling for performance
|
// Handle resize with throttling for performance
|
||||||
this._resizeTimeout = null;
|
this._resizeTimeout = null;
|
||||||
this._lastResizeDims = null;
|
this._lastResizeDims = null;
|
||||||
@@ -1033,6 +1036,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* @returns {Promise<void>} - Resolves when all chunks written
|
* @returns {Promise<void>} - Resolves when all chunks written
|
||||||
*/
|
*/
|
||||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
|
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) => {
|
return new Promise((resolve) => {
|
||||||
if (!buffer || buffer.length === 0) {
|
if (!buffer || buffer.length === 0) {
|
||||||
this._finishBufferLoad();
|
this._finishBufferLoad();
|
||||||
@@ -1049,7 +1056,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
||||||
|
|
||||||
const finish = () => {
|
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();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1067,6 +1077,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const _chunkStart = performance.now();
|
const _chunkStart = performance.now();
|
||||||
let _chunkCount = 0;
|
let _chunkCount = 0;
|
||||||
const writeChunk = () => {
|
const writeChunk = () => {
|
||||||
|
// Abort if a newer chunked write started (user switched tabs)
|
||||||
|
if (this._chunkedWriteGen !== writeGen) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (offset >= cleanBuffer.length) {
|
if (offset >= cleanBuffer.length) {
|
||||||
const _totalMs = performance.now() - _chunkStart;
|
const _totalMs = performance.now() - _chunkStart;
|
||||||
console.log(`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`);
|
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>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async sendResize(sessionId) {
|
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();
|
const dims = this.getTerminalDimensions();
|
||||||
if (!dims) return;
|
if (!dims) return;
|
||||||
// Fast path: WebSocket resize
|
// Fast path: WebSocket resize
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
|
import type { z } from 'zod';
|
||||||
import { Session } from '../session.js';
|
import { Session } from '../session.js';
|
||||||
import { ApiErrorCode, createErrorResponse } from '../types.js';
|
import { ApiErrorCode, createErrorResponse } from '../types.js';
|
||||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||||
@@ -50,6 +51,31 @@ export function findSessionOrFail(ctx: SessionPort, sessionId: string): Session
|
|||||||
return 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").
|
* 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 { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { writeHooksConfig } from '../../hooks-config.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 { SseEvent } from '../sse-events.js';
|
||||||
import type { EventPort, ConfigPort } from '../ports/index.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 } }>> => {
|
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||||
const result = CreateCaseSchema.safeParse(req.body);
|
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
||||||
if (!result.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
||||||
}
|
|
||||||
const { name, description } = result.data;
|
|
||||||
|
|
||||||
const casePath = validatePathWithinBase(name, CASES_DIR);
|
const casePath = validatePathWithinBase(name, CASES_DIR);
|
||||||
if (!casePath) {
|
if (!casePath) {
|
||||||
@@ -120,11 +116,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
|
|
||||||
// Link an existing folder as a case
|
// Link an existing folder as a case
|
||||||
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||||
const lcResult = LinkCaseSchema.safeParse(req.body);
|
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||||
if (!lcResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
||||||
}
|
|
||||||
const { name, path: folderPath } = lcResult.data;
|
|
||||||
|
|
||||||
// Expand ~ to home directory
|
// Expand ~ to home directory
|
||||||
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
|
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||||
import { HookEventSchema, isValidWorkingDir } from '../schemas.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';
|
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||||
|
|
||||||
export function registerHookEventRoutes(
|
export function registerHookEventRoutes(
|
||||||
@@ -15,11 +15,7 @@ export function registerHookEventRoutes(
|
|||||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||||
): void {
|
): void {
|
||||||
app.post('/api/hook-event', async (req) => {
|
app.post('/api/hook-event', async (req) => {
|
||||||
const result = HookEventSchema.safeParse(req.body);
|
const { event, sessionId, data } = parseBody(HookEventSchema, req.body);
|
||||||
if (!result.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
||||||
}
|
|
||||||
const { event, sessionId, data } = result.data;
|
|
||||||
if (!ctx.sessions.has(sessionId)) {
|
if (!ctx.sessions.has(sessionId)) {
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||||
import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js';
|
import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js';
|
||||||
|
import { parseBody } from '../route-helpers.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import type { EventPort, OrchestratorPort } from '../ports/index.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) => {
|
app.post('/api/orchestrator/start', async (req) => {
|
||||||
const parsed = OrchestratorStartSchema.safeParse(req.body);
|
const { goal, config } = parseBody(OrchestratorStartSchema, req.body, 'Invalid request body');
|
||||||
if (!parsed.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { goal, config } = parsed.data;
|
|
||||||
|
|
||||||
// Initialize loop if needed
|
// Initialize loop if needed
|
||||||
let loop = ctx.orchestratorLoop;
|
let loop = ctx.orchestratorLoop;
|
||||||
@@ -141,13 +137,10 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
|||||||
app.post('/api/orchestrator/reject', async (req) => {
|
app.post('/api/orchestrator/reject', async (req) => {
|
||||||
const loop = getLoop();
|
const loop = getLoop();
|
||||||
|
|
||||||
const parsed = OrchestratorRejectSchema.safeParse(req.body);
|
const { feedback } = parseBody(OrchestratorRejectSchema, req.body, 'Feedback is required');
|
||||||
if (!parsed.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Feedback is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loop.reject(parsed.data.feedback).catch((err) => {
|
loop.reject(feedback).catch((err) => {
|
||||||
console.error('[Orchestrator Route] Reject failed:', getErrorMessage(err));
|
console.error('[Orchestrator Route] Reject failed:', getErrorMessage(err));
|
||||||
});
|
});
|
||||||
return { ok: true, state: loop.state };
|
return { ok: true, state: loop.state };
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
PlanTaskUpdateSchema,
|
PlanTaskUpdateSchema,
|
||||||
PlanTaskAddSchema,
|
PlanTaskAddSchema,
|
||||||
} from '../schemas.js';
|
} 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 { SseEvent } from '../sse-events.js';
|
||||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.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) ==========
|
// ========== Generate Plan (Simple) ==========
|
||||||
|
|
||||||
app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
|
app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
|
||||||
const gpResult = GeneratePlanSchema.safeParse(req.body);
|
const { taskDescription, detailLevel = 'standard' } = parseBody(
|
||||||
if (!gpResult.success) {
|
GeneratePlanSchema,
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
req.body,
|
||||||
}
|
'Invalid request body'
|
||||||
const { taskDescription, detailLevel = 'standard' } = gpResult.data;
|
);
|
||||||
|
|
||||||
// Build sophisticated prompt based on Ralph Wiggum methodology
|
// Build sophisticated prompt based on Ralph Wiggum methodology
|
||||||
const detailConfig = {
|
const detailConfig = {
|
||||||
@@ -223,11 +223,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
// ========== Generate Plan (Detailed Orchestration) ==========
|
// ========== Generate Plan (Detailed Orchestration) ==========
|
||||||
|
|
||||||
app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => {
|
app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => {
|
||||||
const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body);
|
const { taskDescription, caseName } = parseBody(GeneratePlanDetailedSchema, req.body, 'Invalid request body');
|
||||||
if (!gpdResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
||||||
}
|
|
||||||
const { taskDescription, caseName } = gpdResult.data;
|
|
||||||
|
|
||||||
// Determine output directory for saving wizard results
|
// Determine output directory for saving wizard results
|
||||||
let outputDir: string | undefined;
|
let outputDir: string | undefined;
|
||||||
@@ -327,11 +323,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
// ========== Cancel Plan Generation ==========
|
// ========== Cancel Plan Generation ==========
|
||||||
|
|
||||||
app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => {
|
app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => {
|
||||||
const cpResult = CancelPlanSchema.safeParse(req.body);
|
const { orchestratorId } = parseBody(CancelPlanSchema, req.body, 'Invalid request body');
|
||||||
if (!cpResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
||||||
}
|
|
||||||
const { orchestratorId } = cpResult.data;
|
|
||||||
|
|
||||||
// If specific orchestrator ID provided, cancel just that one
|
// If specific orchestrator ID provided, cancel just that one
|
||||||
if (orchestratorId) {
|
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');
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ptuResult = PlanTaskUpdateSchema.safeParse(req.body);
|
const update = parseBody(PlanTaskUpdateSchema, req.body, 'Invalid request body') as {
|
||||||
if (!ptuResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
||||||
}
|
|
||||||
const update = ptuResult.data as {
|
|
||||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked';
|
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked';
|
||||||
error?: string;
|
error?: string;
|
||||||
incrementAttempts?: boolean;
|
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');
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ptaResult = PlanTaskAddSchema.safeParse(req.body);
|
const task = parseBody(PlanTaskAddSchema, req.body, 'Invalid request body');
|
||||||
if (!ptaResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
||||||
}
|
|
||||||
const task = ptaResult.data;
|
|
||||||
|
|
||||||
const result = tracker.addPlanTask(task);
|
const result = tracker.addPlanTask(task);
|
||||||
ctx.broadcast(SseEvent.SessionPlanTaskAdded, { sessionId: id, task: result.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 { v4 as uuidv4 } from 'uuid';
|
||||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||||
import { PushSubscribeSchema, PushPreferencesUpdateSchema } from '../schemas.js';
|
import { PushSubscribeSchema, PushPreferencesUpdateSchema } from '../schemas.js';
|
||||||
|
import { parseBody } from '../route-helpers.js';
|
||||||
import type { InfraPort } from '../ports/index.js';
|
import type { InfraPort } from '../ports/index.js';
|
||||||
|
|
||||||
export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
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) => {
|
app.post('/api/push/subscribe', async (req) => {
|
||||||
const result = PushSubscribeSchema.safeParse(req.body);
|
const { endpoint, keys, userAgent, pushPreferences } = parseBody(PushSubscribeSchema, 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 record = ctx.pushStore.addSubscription({
|
const record = ctx.pushStore.addSubscription({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
endpoint,
|
endpoint,
|
||||||
@@ -33,11 +30,8 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
|||||||
|
|
||||||
app.put('/api/push/subscribe/:id', async (req) => {
|
app.put('/api/push/subscribe/:id', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const result = PushPreferencesUpdateSchema.safeParse(req.body);
|
const { pushPreferences } = parseBody(PushPreferencesUpdateSchema, req.body);
|
||||||
if (!result.success) {
|
const updated = ctx.pushStore.updatePreferences(id, pushPreferences);
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
||||||
}
|
|
||||||
const updated = ctx.pushStore.updatePreferences(id, result.data.pushPreferences);
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Session } from '../../session.js';
|
|||||||
import { RespawnController } from '../../respawn-controller.js';
|
import { RespawnController } from '../../respawn-controller.js';
|
||||||
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
||||||
import { SseEvent } from '../sse-events.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 { writeHooksConfig } from '../../hooks-config.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||||
@@ -31,22 +31,18 @@ export function registerRalphRoutes(
|
|||||||
// Configure Ralph tracker for a session
|
// Configure Ralph tracker for a session
|
||||||
app.post('/api/sessions/:id/ralph-config', async (req) => {
|
app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const ralphResult = RalphConfigSchema.safeParse(req.body);
|
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = parseBody(
|
||||||
if (!ralphResult.success) {
|
RalphConfigSchema,
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
req.body,
|
||||||
}
|
'Invalid request body'
|
||||||
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data as {
|
) as {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
completionPhrase?: string;
|
completionPhrase?: string;
|
||||||
maxIterations?: number;
|
maxIterations?: number;
|
||||||
reset?: boolean | 'full';
|
reset?: boolean | 'full';
|
||||||
disableAutoEnable?: boolean;
|
disableAutoEnable?: boolean;
|
||||||
};
|
};
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ralph tracker is not supported for opencode sessions
|
// Ralph tracker is not supported for opencode sessions
|
||||||
if (session.mode === 'opencode') {
|
if (session.mode === 'opencode') {
|
||||||
@@ -111,11 +107,7 @@ export function registerRalphRoutes(
|
|||||||
// Reset circuit breaker for Ralph tracker
|
// Reset circuit breaker for Ralph tracker
|
||||||
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
|
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
session.ralphTracker.resetCircuitBreaker();
|
session.ralphTracker.resetCircuitBreaker();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -124,11 +116,7 @@ export function registerRalphRoutes(
|
|||||||
// Get Ralph status block and circuit breaker state
|
// Get Ralph status block and circuit breaker state
|
||||||
app.get('/api/sessions/:id/ralph-status', async (req) => {
|
app.get('/api/sessions/:id/ralph-status', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -148,11 +136,7 @@ export function registerRalphRoutes(
|
|||||||
// Generate @fix_plan.md content from todos
|
// Generate @fix_plan.md content from todos
|
||||||
app.get('/api/sessions/:id/fix-plan', async (req) => {
|
app.get('/api/sessions/:id/fix-plan', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = session.ralphTracker.generateFixPlanMarkdown();
|
const content = session.ralphTracker.generateFixPlanMarkdown();
|
||||||
return {
|
return {
|
||||||
@@ -167,16 +151,8 @@ export function registerRalphRoutes(
|
|||||||
// Import todos from @fix_plan.md content
|
// Import todos from @fix_plan.md content
|
||||||
app.post('/api/sessions/:id/fix-plan/import', async (req) => {
|
app.post('/api/sessions/:id/fix-plan/import', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const importResult = FixPlanImportSchema.safeParse(req.body);
|
const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body');
|
||||||
if (!importResult.success) {
|
const session = findSessionOrFail(ctx, id);
|
||||||
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 importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
||||||
ctx.persistSessionState(session);
|
ctx.persistSessionState(session);
|
||||||
@@ -193,11 +169,7 @@ export function registerRalphRoutes(
|
|||||||
// Write @fix_plan.md to session's working directory
|
// Write @fix_plan.md to session's working directory
|
||||||
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const workingDir = session.workingDir;
|
const workingDir = session.workingDir;
|
||||||
if (!workingDir) {
|
if (!workingDir) {
|
||||||
@@ -224,11 +196,7 @@ export function registerRalphRoutes(
|
|||||||
// Read @fix_plan.md from session's working directory and import
|
// Read @fix_plan.md from session's working directory and import
|
||||||
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const workingDir = session.workingDir;
|
const workingDir = session.workingDir;
|
||||||
if (!workingDir) {
|
if (!workingDir) {
|
||||||
@@ -266,16 +234,8 @@ export function registerRalphRoutes(
|
|||||||
// This avoids mux input escaping issues with long multi-line prompts
|
// This avoids mux input escaping issues with long multi-line prompts
|
||||||
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const promptResult = RalphPromptWriteSchema.safeParse(req.body);
|
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
|
||||||
if (!promptResult.success) {
|
const session = findSessionOrFail(ctx, id);
|
||||||
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 workingDir = session.workingDir;
|
const workingDir = session.workingDir;
|
||||||
if (!workingDir) {
|
if (!workingDir) {
|
||||||
@@ -308,11 +268,10 @@ export function registerRalphRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rlResult = RalphLoopStartSchema.safeParse(req.body);
|
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = parseBody(
|
||||||
if (!rlResult.success) {
|
RalphLoopStartSchema,
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, rlResult.error.issues[0]?.message ?? 'Validation failed');
|
req.body
|
||||||
}
|
);
|
||||||
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data;
|
|
||||||
|
|
||||||
const casePath = join(CASES_DIR, caseName);
|
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 { RespawnController, type RespawnConfig } from '../../respawn-controller.js';
|
||||||
import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js';
|
import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js';
|
||||||
import { SseEvent } from '../sse-events.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 type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||||
import {
|
import {
|
||||||
@@ -84,11 +84,7 @@ export function registerRespawnRoutes(
|
|||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
let body: Partial<RespawnConfig> | undefined;
|
let body: Partial<RespawnConfig> | undefined;
|
||||||
if (req.body) {
|
if (req.body) {
|
||||||
const result = RespawnConfigSchema.safeParse(req.body);
|
body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||||
if (!result.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config');
|
|
||||||
}
|
|
||||||
body = result.data as Partial<RespawnConfig>;
|
|
||||||
}
|
}
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
@@ -162,11 +158,7 @@ export function registerRespawnRoutes(
|
|||||||
app.put('/api/sessions/:id/respawn/config', async (req) => {
|
app.put('/api/sessions/:id/respawn/config', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
// Validate respawn config to prevent arbitrary field injection
|
// Validate respawn config to prevent arbitrary field injection
|
||||||
const parseResult = RespawnConfigSchema.safeParse(req.body);
|
const config = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||||
if (!parseResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`);
|
|
||||||
}
|
|
||||||
const config = parseResult.data as Partial<RespawnConfig>;
|
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
const controller = ctx.respawnControllers.get(id);
|
const controller = ctx.respawnControllers.get(id);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify';
|
|||||||
import { statSync } from 'node:fs';
|
import { statSync } from 'node:fs';
|
||||||
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||||
import { ScheduledRunSchema } from '../schemas.js';
|
import { ScheduledRunSchema } from '../schemas.js';
|
||||||
|
import { parseBody } from '../route-helpers.js';
|
||||||
import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js';
|
import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js';
|
||||||
|
|
||||||
export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void {
|
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>> => {
|
app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse<never>> => {
|
||||||
const srResult = ScheduledRunSchema.safeParse(req.body);
|
const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body');
|
||||||
if (!srResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
||||||
}
|
|
||||||
const { prompt, workingDir, durationMinutes } = srResult.data;
|
|
||||||
|
|
||||||
// Validate workingDir exists and is a directory
|
// Validate workingDir exists and is a directory
|
||||||
if (workingDir) {
|
if (workingDir) {
|
||||||
|
|||||||
+126
-185
@@ -32,7 +32,15 @@ import {
|
|||||||
QuickRunSchema,
|
QuickRunSchema,
|
||||||
QuickStartSchema,
|
QuickStartSchema,
|
||||||
} from '../schemas.js';
|
} 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 { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||||
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
|
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.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 the redraw section is small (<16KB), not worth stripping
|
||||||
if (redrawPart.length < 16384) return buffer;
|
if (redrawPart.length < 16384) return buffer;
|
||||||
|
|
||||||
// Keep only the last 4KB of redraw frames — this preserves the final visual state
|
// Find the last complete Ink frame by searching for where the VPA row
|
||||||
// (spinner position, status bar text, token count, etc.)
|
// number drops (cursor jumps back to viewport top for a new render cycle).
|
||||||
const tail = redrawPart.slice(-4096);
|
// Search the last 64KB — a single Ink frame with response content can be
|
||||||
// Avoid starting mid-escape: find first complete frame boundary
|
// 10-20KB, so 4KB was too small and caused partial frames (blank gap).
|
||||||
// eslint-disable-next-line no-control-regex
|
const searchLen = Math.min(redrawPart.length, 65536);
|
||||||
const frameStart = tail.search(/\x1b\(B\x1b\[m|\x1b\[\d+d|\x1b\[\d+;\d+H/);
|
const searchWindow = redrawPart.slice(-searchLen);
|
||||||
const cleanTail = frameStart > 0 ? tail.slice(frameStart) : tail;
|
|
||||||
|
|
||||||
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(
|
export function registerSessionRoutes(
|
||||||
@@ -125,11 +146,7 @@ export function registerSessionRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = CreateSessionSchema.safeParse(req.body);
|
const body = parseBody(CreateSessionSchema, req.body);
|
||||||
if (!result.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
||||||
}
|
|
||||||
const body = result.data;
|
|
||||||
const workingDir = body.workingDir || process.cwd();
|
const workingDir = body.workingDir || process.cwd();
|
||||||
|
|
||||||
// Validate workingDir exists and is a directory
|
// Validate workingDir exists and is a directory
|
||||||
@@ -197,23 +214,14 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.put('/api/sessions/:id/name', async (req) => {
|
app.put('/api/sessions/:id/name', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const result = SessionNameSchema.safeParse(req.body);
|
const body = parseBody(SessionNameSchema, req.body, 'Invalid request body');
|
||||||
if (!result.success) {
|
const session = findSessionOrFail(ctx, id);
|
||||||
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 name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||||||
session.name = name;
|
session.name = name;
|
||||||
// Also update the mux session name if applicable
|
// Also update the mux session name if applicable
|
||||||
ctx.mux.updateSessionName(id, session.name);
|
ctx.mux.updateSessionName(id, session.name);
|
||||||
ctx.persistSessionState(session);
|
persistAndBroadcastSession(ctx, session);
|
||||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
|
||||||
return { success: true, name: session.name };
|
return { success: true, name: session.name };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,16 +229,8 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.put('/api/sessions/:id/color', async (req) => {
|
app.put('/api/sessions/:id/color', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const result = SessionColorSchema.safeParse(req.body);
|
const body = parseBody(SessionColorSchema, req.body, 'Invalid request body');
|
||||||
if (!result.success) {
|
const session = findSessionOrFail(ctx, id);
|
||||||
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 validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
||||||
if (!validColors.includes(body.color)) {
|
if (!validColors.includes(body.color)) {
|
||||||
@@ -238,8 +238,7 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.setColor(body.color as SessionColor);
|
session.setColor(body.color as SessionColor);
|
||||||
ctx.persistSessionState(session);
|
persistAndBroadcastSession(ctx, session);
|
||||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
|
||||||
return { success: true, color: session.color };
|
return { success: true, color: session.color };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -278,11 +277,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.get('/api/sessions/:id', async (req) => {
|
app.get('/api/sessions/:id', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
|
// 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).
|
// 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) => {
|
app.get('/api/sessions/:id/output', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -317,11 +308,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.get('/api/sessions/:id/ralph-state', async (req) => {
|
app.get('/api/sessions/:id/ralph-state', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -337,11 +324,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.get('/api/sessions/:id/run-summary', async (req) => {
|
app.get('/api/sessions/:id/run-summary', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tracker = ctx.runSummaryTrackers.get(id);
|
const tracker = ctx.runSummaryTrackers.get(id);
|
||||||
if (!tracker) {
|
if (!tracker) {
|
||||||
@@ -361,11 +344,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.get('/api/sessions/:id/active-tools', async (req) => {
|
app.get('/api/sessions/:id/active-tools', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -383,16 +362,8 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
|
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const result = RunPromptSchema.safeParse(req.body);
|
const { prompt } = parseBody(RunPromptSchema, req.body);
|
||||||
if (!result.success) {
|
const session = findSessionOrFail(ctx, id);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.isBusy()) {
|
if (session.isBusy()) {
|
||||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
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> => {
|
app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.isBusy()) {
|
if (session.isBusy()) {
|
||||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
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> => {
|
app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.isBusy()) {
|
if (session.isBusy()) {
|
||||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
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> => {
|
app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const result = SessionInputWithLimitSchema.safeParse(req.body);
|
const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||||
if (!result.success) {
|
const session = findSessionOrFail(ctx, id);
|
||||||
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 inputStr = String(input);
|
const inputStr = String(input);
|
||||||
if (inputStr.length > MAX_INPUT_LENGTH) {
|
if (inputStr.length > MAX_INPUT_LENGTH) {
|
||||||
@@ -534,16 +489,8 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
|
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const result = ResizeSchema.safeParse(req.body);
|
const { cols, rows } = parseBody(ResizeSchema, req.body);
|
||||||
if (!result.success) {
|
const session = findSessionOrFail(ctx, id);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
session.resize(cols, rows);
|
session.resize(cols, rows);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -556,11 +503,7 @@ export function registerSessionRoutes(
|
|||||||
app.get('/api/sessions/:id/terminal', async (req) => {
|
app.get('/api/sessions/:id/terminal', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const query = req.query as { tail?: string };
|
const query = req.query as { tail?: string };
|
||||||
const session = ctx.sessions.get(id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||||
const fullSize = session.terminalBufferLength;
|
const fullSize = session.terminalBufferLength;
|
||||||
@@ -619,20 +562,11 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const acResult = AutoClearSchema.safeParse(req.body);
|
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
||||||
if (!acResult.success) {
|
const session = findSessionOrFail(ctx, id);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
session.setAutoClear(body.enabled, body.threshold);
|
session.setAutoClear(body.enabled, body.threshold);
|
||||||
ctx.persistSessionState(session);
|
persistAndBroadcastSession(ctx, session);
|
||||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -649,20 +583,11 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const compactResult = AutoCompactSchema.safeParse(req.body);
|
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
||||||
if (!compactResult.success) {
|
const session = findSessionOrFail(ctx, id);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||||
ctx.persistSessionState(session);
|
persistAndBroadcastSession(ctx, session);
|
||||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -680,16 +605,8 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const iwResult = ImageWatcherSchema.safeParse(req.body);
|
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
|
||||||
if (!iwResult.success) {
|
const session = findSessionOrFail(ctx, id);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.enabled) {
|
if (body.enabled) {
|
||||||
imageWatcher.watchSession(session.id, session.workingDir);
|
imageWatcher.watchSession(session.id, session.workingDir);
|
||||||
@@ -713,20 +630,11 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const ffResult = FlickerFilterSchema.safeParse(req.body);
|
const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
|
||||||
if (!ffResult.success) {
|
const session = findSessionOrFail(ctx, id);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
session.flickerFilterEnabled = body.enabled;
|
session.flickerFilterEnabled = body.enabled;
|
||||||
ctx.persistSessionState(session);
|
persistAndBroadcastSession(ctx, session);
|
||||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -751,11 +659,7 @@ export function registerSessionRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const qrResult = QuickRunSchema.safeParse(req.body);
|
const { prompt, workingDir } = parseBody(QuickRunSchema, req.body, 'Invalid request body');
|
||||||
if (!qrResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
||||||
}
|
|
||||||
const { prompt, workingDir } = qrResult.data;
|
|
||||||
|
|
||||||
if (!prompt.trim()) {
|
if (!prompt.trim()) {
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
||||||
@@ -811,11 +715,7 @@ export function registerSessionRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = QuickStartSchema.safeParse(req.body);
|
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = parseBody(QuickStartSchema, 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;
|
|
||||||
|
|
||||||
// Check OpenCode availability if requested
|
// Check OpenCode availability if requested
|
||||||
if (mode === 'opencode') {
|
if (mode === 'opencode') {
|
||||||
@@ -965,7 +865,12 @@ export function registerSessionRoutes(
|
|||||||
/** Extract the text of the first user message from a JSONL transcript head. */
|
/** Extract the text of the first user message from a JSONL transcript head. */
|
||||||
function extractFirstUserPrompt(head: string): string | undefined {
|
function extractFirstUserPrompt(head: string): string | undefined {
|
||||||
const MAX_PROMPT_LEN = 120;
|
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;
|
if (!line.includes('"type":"user"')) continue;
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse(line);
|
const entry = JSON.parse(line);
|
||||||
@@ -979,14 +884,25 @@ export function registerSessionRoutes(
|
|||||||
if (textBlock) text = textBlock.text;
|
if (textBlock) text = textBlock.text;
|
||||||
}
|
}
|
||||||
if (!text) continue;
|
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
|
text = text
|
||||||
.replace(/<[^>]+>/g, '')
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, ' ');
|
.replace(/\s+/g, ' ');
|
||||||
if (!text) continue;
|
if (!text) continue;
|
||||||
// Skip system-injected messages and slash command artifacts (not real user prompts)
|
// Skip system-injected messages, slash command artifacts, and expanded skill prompts
|
||||||
if (/^(Caveat:|init\b|clear\b|\/\w+ \w+$|You are a )/i.test(text)) continue;
|
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;
|
return text.length > MAX_PROMPT_LEN ? text.slice(0, MAX_PROMPT_LEN) + '…' : text;
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed line — skip
|
// Malformed line — skip
|
||||||
@@ -995,6 +911,37 @@ export function registerSessionRoutes(
|
|||||||
return undefined;
|
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 () => {
|
app.get('/api/history/sessions', async () => {
|
||||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||||
const results: Array<{
|
const results: Array<{
|
||||||
@@ -1005,6 +952,7 @@ export function registerSessionRoutes(
|
|||||||
lastModified: string;
|
lastModified: string;
|
||||||
firstPrompt?: string;
|
firstPrompt?: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
const headBuf = Buffer.alloc(16384);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectDirs = await fs.readdir(projectsDir);
|
const projectDirs = await fs.readdir(projectsDir);
|
||||||
@@ -1042,20 +990,9 @@ export function registerSessionRoutes(
|
|||||||
// no "user"/"assistant" messages and will fail claude --resume.
|
// no "user"/"assistant" messages and will fail claude --resume.
|
||||||
// Read first 16KB to check content and extract first user prompt.
|
// Read first 16KB to check content and extract first user prompt.
|
||||||
let firstPrompt: string | undefined;
|
let firstPrompt: string | undefined;
|
||||||
const readHead = async (): Promise<string | null> => {
|
const head = await readFileHead(filePath, headBuf);
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fileStat.size < 50000) {
|
if (fileStat.size < 50000) {
|
||||||
const head = await readHead();
|
|
||||||
if (
|
if (
|
||||||
!head ||
|
!head ||
|
||||||
(!head.includes('"type":"user"') &&
|
(!head.includes('"type":"user"') &&
|
||||||
@@ -1064,11 +1001,15 @@ export function registerSessionRoutes(
|
|||||||
) {
|
) {
|
||||||
continue; // No conversation content — skip
|
continue; // No conversation content — skip
|
||||||
}
|
}
|
||||||
firstPrompt = extractFirstUserPrompt(head);
|
}
|
||||||
} else {
|
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||||
// Large files are almost certainly real conversations; still read head for prompt
|
|
||||||
const head = await readHead();
|
// If head scan found no usable prompt (e.g. session started with /init),
|
||||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
// 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({
|
results.push({
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import { subagentWatcher } from '../../subagent-watcher.js';
|
import { subagentWatcher } from '../../subagent-watcher.js';
|
||||||
import { imageWatcher } from '../../image-watcher.js';
|
import { imageWatcher } from '../../image-watcher.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.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 { SseEvent } from '../sse-events.js';
|
||||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||||
@@ -324,11 +324,8 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/config', async (req) => {
|
app.put('/api/config', async (req) => {
|
||||||
const parseResult = ConfigUpdateSchema.safeParse(req.body);
|
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
|
||||||
if (!parseResult.success) {
|
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`);
|
|
||||||
}
|
|
||||||
ctx.store.setConfig(parseResult.data as Partial<ReturnType<typeof ctx.store.getConfig>>);
|
|
||||||
return { success: true, config: ctx.store.getConfig() };
|
return { success: true, config: ctx.store.getConfig() };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -408,11 +405,7 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/settings', async (req) => {
|
app.put('/api/settings', async (req) => {
|
||||||
const settingsResult = SettingsUpdateSchema.safeParse(req.body);
|
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
|
||||||
if (!settingsResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings');
|
|
||||||
}
|
|
||||||
const settings = settingsResult.data as Record<string, unknown>;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dir = dirname(SETTINGS_PATH);
|
const dir = dirname(SETTINGS_PATH);
|
||||||
@@ -492,11 +485,7 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/execution/model-config', async (req) => {
|
app.put('/api/execution/model-config', async (req) => {
|
||||||
const mcResult = ModelConfigUpdateSchema.safeParse(req.body);
|
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
|
||||||
if (!mcResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config');
|
|
||||||
}
|
|
||||||
const modelConfig = mcResult.data as Record<string, unknown>;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let existingSettings: Record<string, unknown> = {};
|
let existingSettings: Record<string, unknown> = {};
|
||||||
@@ -536,11 +525,7 @@ export function registerSystemRoutes(
|
|||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
const clResult = CpuLimitSchema.safeParse(req.body);
|
const body = parseBody(CpuLimitSchema, req.body, 'Invalid request body') as Partial<NiceConfig>;
|
||||||
if (!clResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
||||||
}
|
|
||||||
const body = clResult.data as Partial<NiceConfig>;
|
|
||||||
|
|
||||||
session.setNice(body);
|
session.setNice(body);
|
||||||
ctx.persistSessionState(session);
|
ctx.persistSessionState(session);
|
||||||
@@ -572,11 +557,7 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/subagent-window-states', async (req) => {
|
app.put('/api/subagent-window-states', async (req) => {
|
||||||
const swResult = SubagentWindowStatesSchema.safeParse(req.body);
|
const states = parseBody(SubagentWindowStatesSchema, req.body, 'Invalid window states') as Record<string, unknown>;
|
||||||
if (!swResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states');
|
|
||||||
}
|
|
||||||
const states = swResult.data as Record<string, unknown>;
|
|
||||||
try {
|
try {
|
||||||
const dir = dirname(windowStatesPath);
|
const dir = dirname(windowStatesPath);
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
@@ -604,11 +585,7 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/subagent-parents', async (req) => {
|
app.put('/api/subagent-parents', async (req) => {
|
||||||
const spResult = SubagentParentMapSchema.safeParse(req.body);
|
const parentMap = parseBody(SubagentParentMapSchema, req.body, 'Invalid parent map');
|
||||||
if (!spResult.success) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map');
|
|
||||||
}
|
|
||||||
const parentMap = spResult.data;
|
|
||||||
try {
|
try {
|
||||||
const dir = dirname(parentMapPath);
|
const dir = dirname(parentMapPath);
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
|
|||||||
Reference in new issue
Block a user