Compare commits

..
Author SHA1 Message Date
arkon 82d08df53f chore: version packages 2026-03-25 00:18:14 +01:00
arkonandClaude Opus 4.6 2709b2fe49 feat: make buffer size limits configurable via environment variables
Allow overriding MAX_TERMINAL_BUFFER_SIZE, TRIM_TERMINAL_TO, MAX_TEXT_OUTPUT_SIZE,
TRIM_TEXT_TO, and MAX_MESSAGES via CODEMAN_* env vars, falling back to existing
defaults. Enables users with fewer sessions or more RAM to tune buffer sizes
without patching source.

Closes #48

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 18:40:40 +01:00
Ark0N b1d3b27e5b Merge pull request #47 from TeigenZhang/fix/mobile-cjk-input-and-layout
fix: mobile CJK input, terminal flicker, and layout overflow
2026-03-24 18:22:48 +01:00
Teigen 47963b54fa fix: mobile CJK input, terminal flicker, and layout overflow
Terminal flicker:
- Skip buffer-recovered/clear-terminal events during active buffer load
  to prevent competing clear+rewrite cycles (app.js)
- Move viewport+scrollback clear inside dimension-change guard so resize
  without actual SIGWINCH doesn't blank the terminal (terminal-ui.js)
- Sync _lastResizeDims on explicit resize to prevent redundant clears

CJK input rewrite (input-cjk.js):
- Use InputEvent.inputType to distinguish insertText (final) from
  insertCompositionText (tentative) — fixes Chinese punctuation and
  English text being swallowed during Android IME composition
- Remove isComposing guard on Enter so it always sends
- Phantom character (U+200B) keeps textarea non-empty so Android
  long-press backspace generates continuous deleteContentBackward
  events at the keyboard's native repeat rate

CJK input settings:
- Add "CJK Input" toggle in Settings > Input (index.html, settings-ui.js)
- Store as device-specific setting (cjkInputEnabled), not synced to server
- Replace INPUT_CJK_FORM env var dependency with user-controlled setting
  (env var still works as server override)

Mobile layout:
- Fix welcome screen overflow on phones by constraining .welcome-content
  to calc(100vw - 1.5rem) (mobile.css)
- Move xterm helper textarea on-screen for touch devices to fix iOS
  keyboard input (styles.css)
- Focus terminal synchronously in user-gesture context for iOS Safari
  keyboard activation (session-ui.js, app.js)
- Refocus terminal on tap (not scroll) in touch handler (terminal-ui.js)
2026-03-24 09:22:06 +08:00
arkonandClaude Opus 4.6 b7c3c30c8c fix: send Ctrl+L after tab switch to clear stale Ink CUP frames
Tailed terminal buffers contain multiple CUP-positioned Ink frames from
different time points. When replayed in xterm, old frames at viewport
positions not covered by the latest frame persist as ghost content
(e.g. duplicate "bypass permissions" bars). After buffer load, send
Ctrl+L via the session input API to trigger a full Ink redraw, which
overwrites all stale frame content with the correct current state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:59:48 +01:00
arkonandClaude Opus 4.6 0d80524f10 fix: prevent duplicate terminal output on tab switch to busy sessions
Two fixes for the tab-switching corruption bug:

1. _finishBufferLoad() now discards queued SSE events instead of flushing
   them. The loaded API buffer is the source of truth — queued events
   overlap with it, and flushing them writes duplicate Ink cursor-up
   redraws that corrupt the terminal display (garbled text, wrong cursor
   positions).

2. Skip stale cache write for busy sessions. When a session is actively
   working, the cache is always outdated — writing it first and then
   rewriting with the fresh API buffer caused a jarring double-render
   flash. Now busy sessions get a single clean clear+write transition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 12:32:33 +01:00
arkonandClaude Opus 4.6 a9d83ec4e3 chore: version packages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:55:39 +01:00
arkonandClaude Opus 4.6 84137cdba4 chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:59:27 +01:00
arkonandClaude Opus 4.6 6eb3969816 fix: avoid no-control-regex lint error for ANSI strip pattern
Use RegExp constructor with String.raw to express \x1b without
a literal control character in the source, matching the pattern
used elsewhere in the codebase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:57:36 +01:00
arkonandClaude Opus 4.6 de49437a6f docs: add browser-testing-guide to CLAUDE.md references, clarify route count
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:38:14 +01:00
arkonandClaude Opus 4.6 6a27639083 fix: increase Ink frame search window from 4KB to 64KB to prevent partial frames
Single Ink frames with response content can be 10-20KB, so the 4KB tail
was too small and caused blank gaps. Now searches the last 64KB for VPA
row drops to find the last complete frame boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:38:09 +01:00
arkonandClaude Opus 4.6 eb1b38c718 fix: align case select group height — stretch buttons to match dropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:06:15 +01:00
arkonandClaude Opus 4.6 ea7b103b47 fix: prevent stale terminal data on tab switch — add chunkedTerminalWrite cancellation
chunkedTerminalWrite used requestAnimationFrame to write buffer chunks across
frames but had no cancellation. When switching tabs, old session's remaining
chunks continued writing stale data into the new session's terminal, causing
visual artifacts and garbled content.

- Add _chunkedWriteGen generation counter to abort in-flight chunked writes
- Bump gen early in selectSession() and SSE reconnect to immediately cancel
- Guard finish() so aborted writes don't flush SSE queue for wrong session
- Add fitAddon.fit() before buffer writes to sync terminal dimensions
- Add fitAddon.fit() in sendResize() to ensure local/server dim parity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:00:58 +01:00
arkonandClaude Opus 4.6 bec8e2f9ee fix: improve history prompt extraction — filter expanded commands, add tail scan fallback
Skip /init expansions, slash commands, orchestrator prompts, ANSI codes, secrets,
and short/vague messages. When head scan finds no usable prompt (e.g. /init sessions),
read last 32KB of transcript to find a recent meaningful user message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:42:50 +01:00
arkonandClaude Opus 4.6 2203f3a347 feat: visual redesign — glass morphism, refined colors, polished UI
Modernize the entire UI with a cohesive "refined dark glass" aesthetic
while preserving all existing functionality.

- Header/toolbar: backdrop-filter blur(16px), semi-transparent backgrounds
- Buttons: 6px radius, multi-stop gradients, inner glow, cubic-bezier transitions
- Welcome screen: gradient text title, radial bg glow, pill-shaped buttons with hover lift
- Panels/modals: glass backgrounds, 12px radius, layered shadows
- Color palette: cooler blue-tinted darks replacing flat blacks
- Forms: refined inputs with focus rings, glass toggle switches
- New CSS vars: --glass-bg, --glass-border, --btn-radius, --transition-smooth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:24:27 +01:00
arkonandClaude Opus 4.6 867a10d78a refactor: optimize history endpoint — reuse buffer, extract readFileHead, use line iterator
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:01:56 +01:00
26 changed files with 1134 additions and 833 deletions
+32
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+10 -5
View File
@@ -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
View File
@@ -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.
+15 -5
View File
@@ -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
View File
@@ -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;
},
+1
View File
@@ -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;
}
+2 -14
View File
@@ -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() {
+1 -7
View File
@@ -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() {
+7
View File
@@ -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
+8 -3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+241 -136
View File
@@ -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
// ═══════════════════════════════════════════════════════════════
+26
View File
@@ -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").
*/
+3 -11
View File
@@ -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;
+2 -6
View File
@@ -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');
}
+4 -11
View File
@@ -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 };
+10 -26
View File
@@ -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 });
+4 -10
View File
@@ -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');
}
+20 -61
View File
@@ -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);
+3 -11
View File
@@ -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);
+2 -5
View File
@@ -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
View File
@@ -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({
+8 -31
View File
@@ -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)) {