mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 04:59:41 +02:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82d08df53f | ||
|
|
2709b2fe49 | ||
|
|
b1d3b27e5b | ||
|
|
47963b54fa | ||
|
|
b7c3c30c8c | ||
|
|
0d80524f10 | ||
|
|
a9d83ec4e3 | ||
|
|
84137cdba4 | ||
|
|
6eb3969816 | ||
|
|
de49437a6f | ||
|
|
6a27639083 | ||
|
|
eb1b38c718 | ||
|
|
ea7b103b47 | ||
|
|
bec8e2f9ee | ||
|
|
2203f3a347 | ||
|
|
867a10d78a |
@@ -1,5 +1,37 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Make buffer size limits configurable via CODEMAN\_\* environment variables (MAX_TERMINAL_BUFFER, TRIM_TERMINAL_TO, MAX_TEXT_OUTPUT, TRIM_TEXT_TO, MAX_MESSAGES), falling back to existing defaults. Allows users with fewer sessions or more RAM to tune buffer sizes without patching source.
|
||||
|
||||
Fix duplicate terminal output on tab switch to busy sessions by clearing the terminal before writing the new buffer.
|
||||
|
||||
Fix stale Ink CUP frames after tab switch by sending Ctrl+L to force a clean redraw.
|
||||
|
||||
Fix mobile CJK input handling: resolve textarea positioning, terminal flicker during composition, and layout overflow on small screens. Improve CJK composition lifecycle with better event handling and fallback flush timers.
|
||||
|
||||
## 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.2 (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.2",
|
||||
"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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
* Maximum terminal buffer size in characters.
|
||||
* Contains raw terminal output with ANSI escape sequences.
|
||||
* Reduced from 5MB to 2MB for better render performance.
|
||||
* Override: CODEMAN_MAX_TERMINAL_BUFFER (bytes)
|
||||
*/
|
||||
export const MAX_TERMINAL_BUFFER_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
export const MAX_TERMINAL_BUFFER_SIZE = parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '') || 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Size to trim terminal buffer to when max is exceeded.
|
||||
* Keeps the most recent portion to preserve context.
|
||||
* Override: CODEMAN_TRIM_TERMINAL_TO (bytes)
|
||||
*/
|
||||
export const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB
|
||||
export const TRIM_TERMINAL_TO = parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '') || 1.5 * 1024 * 1024;
|
||||
|
||||
// ============================================================================
|
||||
// Text Output Buffer Limits
|
||||
@@ -38,13 +40,15 @@ export const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB
|
||||
/**
|
||||
* Maximum text output buffer size in characters.
|
||||
* Contains ANSI-stripped text for search and analysis.
|
||||
* Override: CODEMAN_MAX_TEXT_OUTPUT (bytes)
|
||||
*/
|
||||
export const MAX_TEXT_OUTPUT_SIZE = 1 * 1024 * 1024; // 1MB
|
||||
export const MAX_TEXT_OUTPUT_SIZE = parseInt(process.env.CODEMAN_MAX_TEXT_OUTPUT || '') || 1 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Size to trim text output buffer to when max is exceeded.
|
||||
* Override: CODEMAN_TRIM_TEXT_TO (bytes)
|
||||
*/
|
||||
export const TRIM_TEXT_TO = 768 * 1024; // 768KB
|
||||
export const TRIM_TEXT_TO = parseInt(process.env.CODEMAN_TRIM_TEXT_TO || '') || 768 * 1024;
|
||||
|
||||
// ============================================================================
|
||||
// Message Buffer Limits
|
||||
@@ -53,8 +57,9 @@ export const TRIM_TEXT_TO = 768 * 1024; // 768KB
|
||||
/**
|
||||
* Maximum number of Claude JSON messages to keep in memory per session.
|
||||
* Older messages are discarded when limit is exceeded.
|
||||
* Override: CODEMAN_MAX_MESSAGES (count)
|
||||
*/
|
||||
export const MAX_MESSAGES = 1000;
|
||||
export const MAX_MESSAGES = parseInt(process.env.CODEMAN_MAX_MESSAGES || '') || 1000;
|
||||
|
||||
// ============================================================================
|
||||
// Line Buffer Limits
|
||||
|
||||
+84
-28
@@ -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;
|
||||
@@ -889,6 +886,8 @@ class CodemanApp {
|
||||
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
||||
// so reload the buffer to recover from any display corruption.
|
||||
if (!this.activeSessionId || !this.terminal) return;
|
||||
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
|
||||
if (this._isLoadingBuffer) return;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
const data = await res.json();
|
||||
@@ -912,6 +911,12 @@ class CodemanApp {
|
||||
|
||||
async _onSessionClearTerminal(data) {
|
||||
if (data.id === this.activeSessionId) {
|
||||
// Skip if selectSession is already loading the buffer — clearTerminal arriving
|
||||
// during buffer load would clear the terminal mid-write, causing visible flicker
|
||||
// and a race between two concurrent chunkedTerminalWrite calls (especially on mobile
|
||||
// where rAF is slower). selectSession will handle the final buffer state.
|
||||
if (this._isLoadingBuffer) return;
|
||||
|
||||
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${data.id}/terminal`);
|
||||
@@ -1302,6 +1307,16 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
/** Show/hide the CJK input textarea based on user setting or server override */
|
||||
_updateCjkInputState() {
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (!cjkEl) return;
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const showCjk = this._serverCjkOverride || settings.cjkInputEnabled || false;
|
||||
cjkEl.style.display = showCjk ? 'block' : 'none';
|
||||
if (!showCjk) window.cjkActive = false;
|
||||
}
|
||||
|
||||
handleInit(data) {
|
||||
// Clear the init fallback timer since we got data
|
||||
if (this._initFallbackTimer) {
|
||||
@@ -1310,12 +1325,9 @@ class CodemanApp {
|
||||
}
|
||||
const gen = ++this._initGeneration;
|
||||
|
||||
// CJK input form: show/hide based on server env INPUT_CJK_FORM=ON
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (cjkEl) {
|
||||
cjkEl.style.display = data.inputCjkForm ? 'block' : 'none';
|
||||
if (!data.inputCjkForm) window.cjkActive = false;
|
||||
}
|
||||
// CJK input form: controlled by user setting (with server env as override)
|
||||
this._serverCjkOverride = data.inputCjkForm || false;
|
||||
this._updateCjkInputState();
|
||||
|
||||
// Update version displays (header and toolbar)
|
||||
if (data.version) {
|
||||
@@ -1362,6 +1374,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 +1555,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(). */
|
||||
@@ -1980,6 +2003,12 @@ class CodemanApp {
|
||||
|
||||
async selectSession(sessionId) {
|
||||
if (this.activeSessionId === sessionId) return;
|
||||
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
||||
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
||||
// After the first await the gesture context is lost and focus() is silently
|
||||
// ignored, leaving the keyboard unable to send input to the terminal.
|
||||
if (this.terminal) this.terminal.focus();
|
||||
|
||||
const _selStart = performance.now();
|
||||
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
|
||||
_crashDiag.log(`SELECT: ${_selName}`);
|
||||
@@ -2020,6 +2049,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,11 +2169,19 @@ class CodemanApp {
|
||||
this._isLoadingBuffer = true;
|
||||
this._loadBufferQueue = [];
|
||||
try {
|
||||
// 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.
|
||||
// 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 for IDLE sessions only.
|
||||
// For busy sessions, the cache is always stale — writing it first causes a
|
||||
// jarring double-render: stale content appears, then the terminal flashes
|
||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||
// buffer once for a single clean transition.
|
||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||
if (cachedBuffer) {
|
||||
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
|
||||
if (cachedBuffer && !sessionIsBusy) {
|
||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
@@ -2148,6 +2189,11 @@ class CodemanApp {
|
||||
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
|
||||
this.terminal.scrollToBottom();
|
||||
_crashDiag.log('CACHE_DONE');
|
||||
} else if (sessionIsBusy) {
|
||||
// Clear stale content immediately — fresh buffer is being fetched
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
_crashDiag.log('CACHE_SKIP_BUSY');
|
||||
}
|
||||
|
||||
_crashDiag.log('FETCH_START');
|
||||
@@ -2189,9 +2235,9 @@ class CodemanApp {
|
||||
this.terminal.reset();
|
||||
}
|
||||
|
||||
// Buffer load complete — unblock live SSE writes and flush any queued events.
|
||||
// chunkedTerminalWrite calls _finishBufferLoad internally, but if we skipped
|
||||
// the chunked write (small buffer, cache hit, or empty), we must call it here.
|
||||
// Buffer load complete — unblock live SSE writes (queued events are discarded
|
||||
// to prevent duplicate content). chunkedTerminalWrite calls _finishBufferLoad
|
||||
// internally, but if we skipped the write (cache hit or empty), call it here.
|
||||
if (this._isLoadingBuffer) {
|
||||
this._finishBufferLoad();
|
||||
}
|
||||
@@ -2215,9 +2261,19 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget resize — don't await to avoid blocking UI.
|
||||
// The resize triggers an Ink redraw in Claude which streams back via SSE.
|
||||
this.sendResize(sessionId);
|
||||
// Fire-and-forget resize + Ctrl+L to force Ink redraw.
|
||||
// Tailed buffers accumulate stale CUP-positioned Ink frames that overlap
|
||||
// in the viewport (e.g. duplicate "bypass permissions" bars). Ctrl+L
|
||||
// triggers a full Ink redraw which overwrites all stale frame content.
|
||||
// sendResize may be a no-op if dimensions match, so Ctrl+L is essential.
|
||||
this.sendResize(sessionId).then(() => {
|
||||
if (selectGen !== this._selectGeneration) return;
|
||||
fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' })
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// Defer secondary panel updates so they don't block the main thread
|
||||
// after terminal content is already visible.
|
||||
|
||||
@@ -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>
|
||||
@@ -871,6 +871,16 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item settings-item-multiline" title="Show a dedicated input field below the terminal for CJK (Chinese/Japanese/Korean) IME composition. Recommended for mobile devices with Chinese input methods where xterm's native input handling may drop characters.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">CJK Input</span>
|
||||
<span class="settings-item-desc">Dedicated IME input field for CJK languages</span>
|
||||
</div>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsCjkInput">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Header Displays Section -->
|
||||
<div class="settings-section-header">Header Displays</div>
|
||||
|
||||
+169
-31
@@ -3,10 +3,36 @@
|
||||
*
|
||||
* Always-visible textarea below the terminal (in index.html).
|
||||
* The browser handles IME composition natively — we just read
|
||||
* textarea.value on Enter and send it to PTY.
|
||||
* textarea.value and send it to PTY.
|
||||
* While this textarea has focus, window.cjkActive = true blocks xterm's onData.
|
||||
* Arrow keys and function keys are forwarded to PTY directly.
|
||||
*
|
||||
* ## Android IME challenge
|
||||
*
|
||||
* Android virtual keyboards (WeChat, Sogou, Gboard in Chinese mode) use
|
||||
* composition for EVERYTHING — including English prediction and punctuation.
|
||||
* This means compositionstart fires even for English text, and compositionend
|
||||
* may not fire until the user explicitly confirms (space, candidate tap).
|
||||
*
|
||||
* We use InputEvent.inputType to distinguish:
|
||||
* - `insertCompositionText`: tentative text, may change (CJK candidates, pinyin)
|
||||
* - `insertText`: final committed text (confirmed word, punctuation, space)
|
||||
*
|
||||
* During composition, `insertText` events are flushed immediately (punctuation,
|
||||
* English words confirmed by IME). `insertCompositionText` waits for
|
||||
* compositionend (CJK candidate selection).
|
||||
*
|
||||
* ## Phantom character for Android backspace
|
||||
*
|
||||
* Android virtual keyboards don't generate key-repeat keydown events for held
|
||||
* keys. When the textarea is empty, backspace produces no `input` event either
|
||||
* (nothing to delete). We keep a zero-width space (U+200B) "phantom" in the
|
||||
* textarea at all times. Backspace deletes the phantom → `input` fires with
|
||||
* `deleteContentBackward` → we send \x7f to PTY and restore the phantom.
|
||||
* Long-press backspace generates rapid deleteContentBackward events, each
|
||||
* handled the same way — giving continuous deletion at the keyboard's native
|
||||
* repeat rate.
|
||||
*
|
||||
* @dependency index.html (#cjkInput textarea)
|
||||
* @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData
|
||||
* @loadorder 5.5 of 15 — loaded after keyboard-accessory.js, before app.js
|
||||
@@ -17,10 +43,12 @@ const CjkInput = (() => {
|
||||
let _textarea = null;
|
||||
let _send = null;
|
||||
let _initialized = false;
|
||||
let _onMousedown = null;
|
||||
let _onFocus = null;
|
||||
let _onBlur = null;
|
||||
let _onKeydown = null;
|
||||
let _composing = false;
|
||||
const _listeners = {};
|
||||
|
||||
// Zero-width space: always present in textarea so Android backspace has
|
||||
// something to delete, triggering the `input` event we need to detect it.
|
||||
const PHANTOM = '\u200B';
|
||||
|
||||
const PASSTHROUGH_KEYS = {
|
||||
ArrowUp: '\x1b[A',
|
||||
@@ -36,66 +64,176 @@ const CjkInput = (() => {
|
||||
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
|
||||
};
|
||||
|
||||
/** Strip phantom characters from a string */
|
||||
function _strip(str) {
|
||||
return str.replace(/\u200B/g, '');
|
||||
}
|
||||
|
||||
/** Reset textarea to phantom-only state with cursor at end */
|
||||
function _resetToPhantom() {
|
||||
_textarea.value = PHANTOM;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
|
||||
/** Check if textarea contains only phantom(s) or is empty — no real user text */
|
||||
function _isEffectivelyEmpty() {
|
||||
return !_strip(_textarea.value);
|
||||
}
|
||||
|
||||
/** Flush textarea: send real text to PTY and reset to phantom */
|
||||
function _flush() {
|
||||
const val = _strip(_textarea.value);
|
||||
if (val) {
|
||||
_send(val);
|
||||
}
|
||||
_resetToPhantom();
|
||||
}
|
||||
|
||||
return {
|
||||
init({ send }) {
|
||||
// Guard against double-init: remove previous listeners
|
||||
if (_initialized) this.destroy();
|
||||
|
||||
_send = send;
|
||||
_composing = false;
|
||||
_textarea = document.getElementById('cjkInput');
|
||||
if (!_textarea) return this;
|
||||
|
||||
_onMousedown = (e) => { e.stopPropagation(); };
|
||||
_onFocus = () => { window.cjkActive = true; };
|
||||
_onBlur = () => { window.cjkActive = false; };
|
||||
_textarea.addEventListener('mousedown', _onMousedown);
|
||||
_textarea.addEventListener('focus', _onFocus);
|
||||
_textarea.addEventListener('blur', _onBlur);
|
||||
// Seed the phantom character
|
||||
_resetToPhantom();
|
||||
|
||||
_onKeydown = (e) => {
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
||||
_listeners.focus = () => {
|
||||
window.cjkActive = true;
|
||||
// Restore phantom if textarea was emptied while blurred
|
||||
if (!_textarea.value) _resetToPhantom();
|
||||
};
|
||||
_listeners.blur = () => { window.cjkActive = false; };
|
||||
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
||||
_textarea.addEventListener('focus', _listeners.focus);
|
||||
_textarea.addEventListener('blur', _listeners.blur);
|
||||
|
||||
// Enter: send accumulated text (or bare Enter if empty)
|
||||
// ── Composition tracking ──
|
||||
_listeners.compositionstart = () => {
|
||||
_composing = true;
|
||||
// Clear phantom so IME sees a clean textarea — some IMEs include
|
||||
// existing text in the composition region which would corrupt input.
|
||||
if (_textarea.value === PHANTOM) {
|
||||
_textarea.value = '';
|
||||
}
|
||||
};
|
||||
_listeners.compositionend = () => {
|
||||
_composing = false;
|
||||
// Defer flush: some Android IMEs haven't committed text to textarea
|
||||
// when compositionend fires. setTimeout(0) ensures we read the final value.
|
||||
setTimeout(_flush, 0);
|
||||
};
|
||||
_textarea.addEventListener('compositionstart', _listeners.compositionstart);
|
||||
_textarea.addEventListener('compositionend', _listeners.compositionend);
|
||||
|
||||
// ── Keydown: special keys work REGARDLESS of composition state ──
|
||||
_listeners.keydown = (e) => {
|
||||
// Enter: flush accumulated text (or bare Enter if empty).
|
||||
// No isComposing guard — Android IMEs set isComposing=true for English
|
||||
// prediction, but Enter should ALWAYS send. We preventDefault to stop
|
||||
// the IME from also handling Enter (which could double-send or do nothing).
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (_textarea.value) {
|
||||
_send(_textarea.value + '\r');
|
||||
_textarea.value = '';
|
||||
_composing = false;
|
||||
const val = _strip(_textarea.value);
|
||||
if (val) {
|
||||
_send(val + '\r');
|
||||
} else {
|
||||
_send('\r');
|
||||
}
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: clear textarea
|
||||
// Escape: clear textarea (always works)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
_textarea.value = '';
|
||||
_composing = false;
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl combos: forward to PTY
|
||||
// Ctrl combos: forward to PTY (always works)
|
||||
if (e.ctrlKey && CTRL_KEYS[e.key]) {
|
||||
e.preventDefault();
|
||||
_send(CTRL_KEYS[e.key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace: delete from textarea if has text, else forward to PTY
|
||||
if (e.key === 'Backspace' && !_textarea.value) {
|
||||
// Below: only when NOT composing (composing keystrokes belong to IME)
|
||||
if (_composing) return;
|
||||
|
||||
// Backspace: forward to PTY when no real text in textarea
|
||||
// (Desktop path — Android uses the input event + phantom approach)
|
||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send('\x7f');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow/function keys: forward to PTY when textarea is empty
|
||||
if (PASSTHROUGH_KEYS[e.key] && !_textarea.value) {
|
||||
// Arrow/function keys: forward to PTY when no real text
|
||||
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(PASSTHROUGH_KEYS[e.key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single printable character: send immediately to PTY
|
||||
// (Desktop keyboards with physical keys — Android sends 'Unidentified')
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(e.key);
|
||||
return;
|
||||
}
|
||||
};
|
||||
_textarea.addEventListener('keydown', _onKeydown);
|
||||
_textarea.addEventListener('keydown', _listeners.keydown);
|
||||
|
||||
// ── Input event: the primary path for Android virtual keyboards ──
|
||||
// Android sends keyCode 229 + key "Unidentified" for virtual key presses,
|
||||
// making keydown unreliable. input fires AFTER character insertion and
|
||||
// carries inputType which tells us whether the text is final or tentative.
|
||||
_listeners.input = (e) => {
|
||||
// ── Backspace / delete detection ──
|
||||
// Android long-press backspace generates rapid deleteContentBackward events.
|
||||
// The phantom character ensures the textarea is never truly empty, so each
|
||||
// press/repeat fires an input event that we can catch here.
|
||||
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
|
||||
if (_isEffectivelyEmpty()) {
|
||||
// No real text left — forward backspace to PTY
|
||||
_send('\x7f');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
// User is editing their own text in the textarea — let it be.
|
||||
// Ensure phantom is still present for the NEXT backspace.
|
||||
if (!_textarea.value.startsWith(PHANTOM)) {
|
||||
_textarea.value = PHANTOM + _textarea.value;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_composing) {
|
||||
// insertText during composition = IME committed final text
|
||||
// (e.g., punctuation key inserts 。directly, or IME confirms a word).
|
||||
// Flush immediately — this text won't change.
|
||||
if (e.inputType === 'insertText') {
|
||||
_flush();
|
||||
return;
|
||||
}
|
||||
// insertCompositionText = IME is still working (pinyin, candidates,
|
||||
// English prediction). Wait for compositionend to flush.
|
||||
return;
|
||||
}
|
||||
// Outside composition: send immediately
|
||||
_flush();
|
||||
};
|
||||
_textarea.addEventListener('input', _listeners.input);
|
||||
|
||||
_initialized = true;
|
||||
return this;
|
||||
@@ -103,13 +241,13 @@ const CjkInput = (() => {
|
||||
|
||||
destroy() {
|
||||
if (_textarea) {
|
||||
if (_onMousedown) _textarea.removeEventListener('mousedown', _onMousedown);
|
||||
if (_onFocus) _textarea.removeEventListener('focus', _onFocus);
|
||||
if (_onBlur) _textarea.removeEventListener('blur', _onBlur);
|
||||
if (_onKeydown) _textarea.removeEventListener('keydown', _onKeydown);
|
||||
for (const [event, handler] of Object.entries(_listeners)) {
|
||||
if (handler) _textarea.removeEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
window.cjkActive = false;
|
||||
_onMousedown = _onFocus = _onBlur = _onKeydown = null;
|
||||
_composing = false;
|
||||
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
||||
_initialized = false;
|
||||
},
|
||||
|
||||
|
||||
@@ -1147,6 +1147,7 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
/* Compact welcome overlay for mobile */
|
||||
.welcome-content {
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -273,6 +273,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
// Focus terminal NOW, in the synchronous user-gesture context (button click).
|
||||
// iOS Safari ignores programmatic focus() after any await, so this must happen
|
||||
// before the first async call. The keyboard opens here and stays open through
|
||||
// the session creation flow; selectSession at the end inherits the focus state.
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Get case path first
|
||||
@@ -493,6 +498,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
// Focus in sync gesture context (see runClaude comment)
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Check if OpenCode is available
|
||||
|
||||
@@ -364,6 +364,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
// Claude CLI settings
|
||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||
@@ -1180,6 +1181,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
@@ -1296,9 +1298,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
|
||||
this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting
|
||||
|
||||
// Apply CJK input visibility immediately
|
||||
this._updateCjkInputState();
|
||||
|
||||
// Save to server (includes notification prefs for cross-browser persistence)
|
||||
// Strip device-specific keys — localEchoEnabled is per-platform (touch default differs)
|
||||
const { localEchoEnabled: _leo, ...serverSettings } = settings;
|
||||
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
|
||||
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, ...serverSettings } = settings;
|
||||
try {
|
||||
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
|
||||
|
||||
@@ -1682,7 +1687,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled',
|
||||
]);
|
||||
// Merge settings: non-display keys always sync from server,
|
||||
// display keys only seed from server when localStorage has no value
|
||||
|
||||
+339
-237
File diff suppressed because it is too large
Load Diff
+241
-136
@@ -64,7 +64,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
||||
this.terminal.loadAddon(unicode11Addon);
|
||||
this.terminal.unicode.activeVersion = '11';
|
||||
} catch (_e) { /* Unicode11 addon failed — default Unicode handling used */ }
|
||||
} catch (_e) {
|
||||
/* Unicode11 addon failed — default Unicode handling used */
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.getElementById('terminalContainer');
|
||||
@@ -115,8 +117,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// On mobile Safari, delay initial fit() to allow layout to settle
|
||||
// This prevents 0-column terminals caused by fit() running before container is sized
|
||||
const isMobileSafari = MobileDetection.getDeviceType() === 'mobile' &&
|
||||
document.body.classList.contains('safari-browser');
|
||||
const isMobileSafari =
|
||||
MobileDetection.getDeviceType() === 'mobile' && document.body.classList.contains('safari-browser');
|
||||
if (isMobileSafari) {
|
||||
// Wait for layout, then fit multiple times to ensure proper sizing
|
||||
requestAnimationFrame(() => {
|
||||
@@ -133,11 +135,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Always use mouse wheel for terminal scrollback, never forward to application.
|
||||
// Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation.
|
||||
container.addEventListener('wheel', (ev) => {
|
||||
ev.preventDefault();
|
||||
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
|
||||
this.terminal.scrollLines(lines);
|
||||
}, { passive: false });
|
||||
container.addEventListener(
|
||||
'wheel',
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
|
||||
this.terminal.scrollLines(lines);
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// Touch scrolling — use terminal.scrollLines() for all devices.
|
||||
// xterm.js DOM renderer doesn't populate xterm-viewport's scroll area,
|
||||
@@ -145,8 +151,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// has nothing to scroll. Instead, convert touch deltas into scrollLines()
|
||||
// calls, matching the wheel handler above.
|
||||
{
|
||||
const cellHeight = () =>
|
||||
this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
|
||||
const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
|
||||
let touchLastY = 0;
|
||||
let velocity = 0;
|
||||
let lastTime = 0;
|
||||
@@ -174,51 +179,73 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Accumulate sub-line pixel deltas so slow swipes still scroll
|
||||
let pixelAccum = 0;
|
||||
|
||||
container.addEventListener('touchstart', (ev) => {
|
||||
if (ev.touches.length === 1) {
|
||||
touchLastY = ev.touches[0].clientY;
|
||||
container.addEventListener(
|
||||
'touchstart',
|
||||
(ev) => {
|
||||
if (ev.touches.length === 1) {
|
||||
touchLastY = ev.touches[0].clientY;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
lastTime = 0;
|
||||
if (scrollFrame) {
|
||||
cancelAnimationFrame(scrollFrame);
|
||||
scrollFrame = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
'touchmove',
|
||||
(ev) => {
|
||||
if (ev.touches.length === 1 && isTouching) {
|
||||
const touchY = ev.touches[0].clientY;
|
||||
const delta = touchLastY - touchY; // positive = scroll down
|
||||
pixelAccum += delta;
|
||||
velocity = delta * 1.2;
|
||||
touchLastY = touchY;
|
||||
// Convert accumulated pixels to whole lines
|
||||
const ch = cellHeight();
|
||||
const lines = Math.trunc(pixelAccum / ch);
|
||||
if (lines !== 0) {
|
||||
this.terminal.scrollLines(lines);
|
||||
pixelAccum -= lines * ch;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
'touchend',
|
||||
() => {
|
||||
isTouching = false;
|
||||
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
'touchcancel',
|
||||
() => {
|
||||
isTouching = false;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
lastTime = 0;
|
||||
if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchmove', (ev) => {
|
||||
if (ev.touches.length === 1 && isTouching) {
|
||||
const touchY = ev.touches[0].clientY;
|
||||
const delta = touchLastY - touchY; // positive = scroll down
|
||||
pixelAccum += delta;
|
||||
velocity = delta * 1.2;
|
||||
touchLastY = touchY;
|
||||
// Convert accumulated pixels to whole lines
|
||||
const ch = cellHeight();
|
||||
const lines = Math.trunc(pixelAccum / ch);
|
||||
if (lines !== 0) {
|
||||
this.terminal.scrollLines(lines);
|
||||
pixelAccum -= lines * ch;
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchend', () => {
|
||||
isTouching = false;
|
||||
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchcancel', () => {
|
||||
isTouching = false;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
}, { passive: true });
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -264,8 +291,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
|
||||
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
|
||||
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
if (activeResizeSession && activeResizeSession.mode !== 'shell' && !activeResizeSession._ended
|
||||
&& this.terminal && this.isTerminalAtBottom()) {
|
||||
if (
|
||||
activeResizeSession &&
|
||||
activeResizeSession.mode !== 'shell' &&
|
||||
!activeResizeSession._ended &&
|
||||
this.terminal &&
|
||||
this.isTerminalAtBottom()
|
||||
) {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
|
||||
@@ -278,14 +310,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
|
||||
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
|
||||
// Only send resize if dimensions actually changed
|
||||
if (!this._lastResizeDims ||
|
||||
cols !== this._lastResizeDims.cols ||
|
||||
rows !== this._lastResizeDims.rows) {
|
||||
if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
|
||||
this._lastResizeDims = { cols, rows };
|
||||
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows })
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -439,7 +469,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (p) {
|
||||
const buf = this.terminal.buffer.active;
|
||||
const line = buf.getLine(buf.viewportY + p.row);
|
||||
if (line) baseText = line.translateToString(true).slice(p.col + 2).trimEnd();
|
||||
if (line)
|
||||
baseText = line
|
||||
.translateToString(true)
|
||||
.slice(p.col + 2)
|
||||
.trimEnd();
|
||||
}
|
||||
} catch {}
|
||||
this._tabCompletionBaseText = baseText;
|
||||
@@ -454,7 +488,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const selfTab = this;
|
||||
this._tabCompletionFallback = setTimeout(() => {
|
||||
selfTab._tabCompletionFallback = null;
|
||||
if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId) return;
|
||||
if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId)
|
||||
return;
|
||||
const ov = selfTab._localEchoOverlay;
|
||||
if (!ov || ov.pendingText) return;
|
||||
selfTab.terminal.write('', () => {
|
||||
@@ -553,7 +588,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const buffer = self.terminal.buffer.active;
|
||||
const line = buffer.getLine(bufferLineNumber);
|
||||
// provideLinks passes 1-based line number, getLine expects 0-based
|
||||
const line = buffer.getLine(bufferLineNumber - 1);
|
||||
|
||||
if (!line) {
|
||||
callback(undefined);
|
||||
@@ -570,12 +606,37 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const links = [];
|
||||
|
||||
// Pattern 0: URLs (https://, http://) — matched first so they take priority
|
||||
const urlPattern = /https?:\/\/[^\s"'<>|;&)\]\x00-\x1f]+/g;
|
||||
|
||||
const addUrlLink = (url, matchIndex) => {
|
||||
// Strip trailing punctuation that's likely not part of the URL
|
||||
const cleaned = url.replace(/[.,;:!?)]+$/, '');
|
||||
const startCol = lineText.indexOf(cleaned, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
|
||||
links.push({
|
||||
text: cleaned,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber },
|
||||
end: { x: startCol + cleaned.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(_event, text) {
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
|
||||
// Handles: tail -f /path, grep pattern /path, cat -n /path
|
||||
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
|
||||
// Pattern 2: Paths with common extensions
|
||||
const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
const extPattern =
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
@@ -585,27 +646,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (startCol === -1) return;
|
||||
|
||||
// Skip if already have link at this position
|
||||
if (links.some(l => l.range.start.x === startCol + 1)) return;
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
|
||||
links.push({
|
||||
text: filePath,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
|
||||
end: { x: startCol + filePath.length + 1, y: bufferLineNumber }
|
||||
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
|
||||
end: { x: startCol + filePath.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
decorations: {
|
||||
pointerCursor: true,
|
||||
underline: true
|
||||
underline: true,
|
||||
},
|
||||
activate(event, text) {
|
||||
self.openLogViewerWindow(text, self.activeSessionId);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Match all patterns
|
||||
// Match all patterns — URLs first so they take priority
|
||||
let match;
|
||||
|
||||
urlPattern.lastIndex = 0;
|
||||
while ((match = urlPattern.exec(lineText)) !== null) {
|
||||
addUrlLink(match[0], match.index);
|
||||
}
|
||||
|
||||
cmdPattern.lastIndex = 0;
|
||||
while ((match = cmdPattern.exec(lineText)) !== null) {
|
||||
addLink(match[2], match.index);
|
||||
@@ -622,10 +688,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
if (links.length > 0) {
|
||||
console.debug('[LinkProvider] Found links:', links.map(l => l.text));
|
||||
console.debug(
|
||||
'[LinkProvider] Found links:',
|
||||
links.map((l) => l.text)
|
||||
);
|
||||
}
|
||||
callback(links.length > 0 ? links : undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[LinkProvider] File path link provider registered');
|
||||
@@ -691,12 +760,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Build DOM safely (no innerHTML with user data)
|
||||
list.replaceChildren();
|
||||
for (const s of display) {
|
||||
const size = s.sizeBytes < 1024 ? `${s.sizeBytes}B`
|
||||
: s.sizeBytes < 1048576 ? `${(s.sizeBytes / 1024).toFixed(0)}K`
|
||||
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
|
||||
const size =
|
||||
s.sizeBytes < 1024
|
||||
? `${s.sizeBytes}B`
|
||||
: s.sizeBytes < 1048576
|
||||
? `${(s.sizeBytes / 1024).toFixed(0)}K`
|
||||
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
|
||||
const date = new Date(s.lastModified);
|
||||
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const timeStr =
|
||||
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
|
||||
|
||||
const item = document.createElement('div');
|
||||
@@ -759,7 +833,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId })
|
||||
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
if (!createData.success) throw new Error(createData.error);
|
||||
@@ -777,7 +851,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Terminal Rendering
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -822,9 +895,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Opt-in flicker filter: buffer screen clear patterns (for sessions that enable it)
|
||||
if (flickerFilterEnabled) {
|
||||
const hasScreenClear = data.includes('\x1b[2J') ||
|
||||
data.includes('\x1b[H\x1b[J') ||
|
||||
(data.includes('\x1b[H') && data.includes('\x1b[?25l'));
|
||||
const hasScreenClear =
|
||||
data.includes('\x1b[2J') ||
|
||||
data.includes('\x1b[H\x1b[J') ||
|
||||
(data.includes('\x1b[H') && data.includes('\x1b[?25l'));
|
||||
|
||||
if (hasScreenClear) {
|
||||
this.flickerFilterActive = true;
|
||||
@@ -890,47 +964,49 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Position is tracked dynamically by _findPrompt() on every render.
|
||||
*/
|
||||
_updateLocalEchoState() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
const shouldEnable = !!(echoEnabled && session);
|
||||
if (this._localEchoEnabled && !shouldEnable) {
|
||||
this._localEchoOverlay?.clear();
|
||||
}
|
||||
this._localEchoEnabled = shouldEnable;
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
const shouldEnable = !!(echoEnabled && session);
|
||||
if (this._localEchoEnabled && !shouldEnable) {
|
||||
this._localEchoOverlay?.clear();
|
||||
}
|
||||
this._localEchoEnabled = shouldEnable;
|
||||
|
||||
// Swap prompt finder based on session mode
|
||||
if (this._localEchoOverlay && session) {
|
||||
if (session.mode === 'opencode') {
|
||||
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
|
||||
// The input area is "┃ <text>" — the ┃ is the anchor, offset 3 skips "┃ ".
|
||||
// We use the cursor row (cursorY) to find the right line, then scan for ┃.
|
||||
this._localEchoOverlay.setPrompt({
|
||||
type: 'custom',
|
||||
offset: 3,
|
||||
find: (terminal) => {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
const row = buf.cursorY;
|
||||
const line = buf.getLine(buf.viewportY + row);
|
||||
if (!line) return null;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.indexOf('\u2503'); // ┃ (BOX DRAWINGS HEAVY VERTICAL)
|
||||
if (idx >= 0) return { row, col: idx };
|
||||
return null;
|
||||
} catch { return null; }
|
||||
// Swap prompt finder based on session mode
|
||||
if (this._localEchoOverlay && session) {
|
||||
if (session.mode === 'opencode') {
|
||||
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
|
||||
// The input area is "┃ <text>" — the ┃ is the anchor, offset 3 skips "┃ ".
|
||||
// We use the cursor row (cursorY) to find the right line, then scan for ┃.
|
||||
this._localEchoOverlay.setPrompt({
|
||||
type: 'custom',
|
||||
offset: 3,
|
||||
find: (terminal) => {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
const row = buf.cursorY;
|
||||
const line = buf.getLine(buf.viewportY + row);
|
||||
if (!line) return null;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.indexOf('\u2503'); // ┃ (BOX DRAWINGS HEAVY VERTICAL)
|
||||
if (idx >= 0) return { row, col: idx };
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
} else if (session.mode === 'shell') {
|
||||
// Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
|
||||
// Disable it by clearing any pending text.
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoEnabled = false;
|
||||
} else {
|
||||
// Claude Code: scan for ❯ prompt character
|
||||
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (session.mode === 'shell') {
|
||||
// Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
|
||||
// Disable it by clearing any pending text.
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoEnabled = false;
|
||||
} else {
|
||||
// Claude Code: scan for ❯ prompt character
|
||||
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -947,7 +1023,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const joined = this.pendingWrites.join('');
|
||||
this.pendingWrites = [];
|
||||
const _joinedLen = joined.length;
|
||||
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`);
|
||||
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen / 1024).toFixed(0)}KB`);
|
||||
|
||||
// Per-frame byte budget to prevent main thread blocking.
|
||||
// Large writes (141KB+) can freeze Chrome for 2+ minutes.
|
||||
@@ -971,7 +1047,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
|
||||
const _dt = performance.now() - _t0;
|
||||
if (_dt > 100 || deferred) console.warn(`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame/1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen/1024).toFixed(0)}KB)`);
|
||||
if (_dt > 100 || deferred)
|
||||
console.warn(
|
||||
`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame / 1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen / 1024).toFixed(0)}KB)`
|
||||
);
|
||||
|
||||
// Sticky scroll: if user was at bottom, keep them there after new output
|
||||
if (this._wasAtBottomBeforeWrite) {
|
||||
@@ -988,8 +1067,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Use terminal.write('', callback) to defer detection until xterm.js
|
||||
// finishes processing ALL queued writes — direct buffer reads after
|
||||
// terminal.write(data) can miss text if xterm processes asynchronously.
|
||||
if (this._tabCompletionSessionId && this._tabCompletionSessionId === this.activeSessionId
|
||||
&& this._localEchoOverlay && !this._localEchoOverlay.pendingText) {
|
||||
if (
|
||||
this._tabCompletionSessionId &&
|
||||
this._tabCompletionSessionId === this.activeSessionId &&
|
||||
this._localEchoOverlay &&
|
||||
!this._localEchoOverlay.pendingText
|
||||
) {
|
||||
const overlay = this._localEchoOverlay;
|
||||
const self = this;
|
||||
this.terminal.write('', () => {
|
||||
@@ -1010,7 +1093,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
self._tabCompletionSessionId = null;
|
||||
self._tabCompletionRetries = 0;
|
||||
self._tabCompletionBaseText = null;
|
||||
if (self._tabCompletionFallback) { clearTimeout(self._tabCompletionFallback); self._tabCompletionFallback = null; }
|
||||
if (self._tabCompletionFallback) {
|
||||
clearTimeout(self._tabCompletionFallback);
|
||||
self._tabCompletionFallback = null;
|
||||
}
|
||||
overlay.rerender();
|
||||
}
|
||||
} else {
|
||||
@@ -1033,6 +1119,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 +1139,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,9 +1160,17 @@ 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`);
|
||||
console.log(
|
||||
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`
|
||||
);
|
||||
// Wait one more frame for xterm to finish rendering before resolving
|
||||
requestAnimationFrame(finish);
|
||||
return;
|
||||
@@ -1080,7 +1181,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.write(chunk);
|
||||
const _cdt = performance.now() - _ct0;
|
||||
_chunkCount++;
|
||||
if (_cdt > 50) console.warn(`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`);
|
||||
if (_cdt > 50)
|
||||
console.warn(
|
||||
`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`
|
||||
);
|
||||
offset += chunkSize;
|
||||
|
||||
// Schedule next chunk on next frame
|
||||
@@ -1093,21 +1197,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete a buffer load: unblock live SSE writes and flush any queued events.
|
||||
* Complete a buffer load: unblock live SSE writes.
|
||||
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
|
||||
*
|
||||
* Queued SSE events are DISCARDED, not flushed. The loaded buffer from the API
|
||||
* is the source of truth up to the response timestamp. SSE events queued during
|
||||
* the fetch+write overlap with the buffer — flushing them writes duplicate data
|
||||
* (especially Ink cursor-up redraws), corrupting the terminal display.
|
||||
* After unblocking, new SSE/WS events deliver subsequent output normally.
|
||||
*/
|
||||
_finishBufferLoad() {
|
||||
const queue = this._loadBufferQueue;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
if (queue && queue.length > 0) {
|
||||
for (const data of queue) {
|
||||
this.batchTerminalWrite(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Terminal Controls
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1141,7 +1244,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' })
|
||||
body: JSON.stringify({ input: '\x0c' }),
|
||||
});
|
||||
|
||||
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
|
||||
@@ -1168,7 +1271,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' })
|
||||
body: JSON.stringify({ input: '\x0c' }),
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -1230,7 +1333,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!dims) return null;
|
||||
return {
|
||||
cols: Math.max(dims.cols, MIN_COLS),
|
||||
rows: Math.max(dims.rows, MIN_ROWS)
|
||||
rows: Math.max(dims.rows, MIN_ROWS),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1240,6 +1343,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
|
||||
@@ -1254,7 +1360,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dims)
|
||||
body: JSON.stringify(dims),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1268,11 +1374,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, useMux: true })
|
||||
body: JSON.stringify({ input, useMux: true }),
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Directory Input
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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