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
26 changed files with 681 additions and 560 deletions

No files matched your search

+26
View File
@@ -1,5 +1,31 @@
# aicodeman # 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 ## 0.5.0
### Minor Changes ### Minor Changes
+3 -3
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` 4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web` 5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.5.0 (must match `package.json`) **Version**: 0.5.2 (must match `package.json`)
## Project Overview ## Project Overview
@@ -111,7 +111,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | | | **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | | | **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` → 15 domain files | See `@fileoverview` in index.ts | | **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. ★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
@@ -119,7 +119,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Config**: `src/config/` — 9 files. Import from specific files, not barrel. **Config**: `src/config/` — 9 files. Import from specific files, not barrel.
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks). **Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
### Data Flow ### Data Flow
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.5.0", "version": "0.5.2",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+11 -4
View File
@@ -498,10 +498,17 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
// Enforce max tools limit // Enforce max tools limit
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) { if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
// Remove oldest tool // Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0]; let oldestKey: string | undefined;
if (oldest) { let oldestTime = Infinity;
this._activeTools.delete(oldest[0]); for (const [key, entry] of this._activeTools) {
if (entry.startedAt < oldestTime) {
oldestTime = entry.startedAt;
oldestKey = key;
}
}
if (oldestKey) {
this._activeTools.delete(oldestKey);
} }
} }
+10 -5
View File
@@ -22,14 +22,16 @@
* Maximum terminal buffer size in characters. * Maximum terminal buffer size in characters.
* Contains raw terminal output with ANSI escape sequences. * Contains raw terminal output with ANSI escape sequences.
* Reduced from 5MB to 2MB for better render performance. * 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. * Size to trim terminal buffer to when max is exceeded.
* Keeps the most recent portion to preserve context. * 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 // 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. * Maximum text output buffer size in characters.
* Contains ANSI-stripped text for search and analysis. * 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. * 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 // 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. * Maximum number of Claude JSON messages to keep in memory per session.
* Older messages are discarded when limit is exceeded. * 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 // Line Buffer Limits
+73 -28
View File
@@ -380,11 +380,8 @@ class CodemanApp {
this.flickerFilterActive = false; this.flickerFilterActive = false;
this.flickerFilterTimeout = null; this.flickerFilterTimeout = null;
// Render debouncing // Render debounce timers (managed by _debouncedCall)
this.renderSessionTabsTimeout = null; this._debounceTimers = Object.create(null);
this.renderRalphStatePanelTimeout = null;
this.renderTaskPanelTimeout = null;
this.renderMuxSessionsTimeout = null;
// System stats polling // System stats polling
this.systemStatsInterval = null; this.systemStatsInterval = null;
@@ -889,6 +886,8 @@ class CodemanApp {
// Server sends this after SSE backpressure clears — terminal data was dropped, // Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption. // so reload the buffer to recover from any display corruption.
if (!this.activeSessionId || !this.terminal) return; if (!this.activeSessionId || !this.terminal) return;
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
if (this._isLoadingBuffer) return;
try { try {
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
const data = await res.json(); const data = await res.json();
@@ -912,6 +911,12 @@ class CodemanApp {
async _onSessionClearTerminal(data) { async _onSessionClearTerminal(data) {
if (data.id === this.activeSessionId) { 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) // Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
try { try {
const res = await fetch(`/api/sessions/${data.id}/terminal`); 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) { handleInit(data) {
// Clear the init fallback timer since we got data // Clear the init fallback timer since we got data
if (this._initFallbackTimer) { if (this._initFallbackTimer) {
@@ -1310,12 +1325,9 @@ class CodemanApp {
} }
const gen = ++this._initGeneration; const gen = ++this._initGeneration;
// CJK input form: show/hide based on server env INPUT_CJK_FORM=ON // CJK input form: controlled by user setting (with server env as override)
const cjkEl = document.getElementById('cjkInput'); this._serverCjkOverride = data.inputCjkForm || false;
if (cjkEl) { this._updateCjkInputState();
cjkEl.style.display = data.inputCjkForm ? 'block' : 'none';
if (!data.inputCjkForm) window.cjkActive = false;
}
// Update version displays (header and toolbar) // Update version displays (header and toolbar)
if (data.version) { if (data.version) {
@@ -1543,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 // Session Tabs
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
renderSessionTabs() { renderSessionTabs() {
// Debounce renders at 100ms to prevent excessive DOM updates this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
if (this.renderSessionTabsTimeout) {
clearTimeout(this.renderSessionTabsTimeout);
}
this.renderSessionTabsTimeout = setTimeout(() => {
this._renderSessionTabsImmediate();
}, 100);
} }
/** Toggle .active class on tabs immediately (no debounce). Used by selectSession(). */ /** Toggle .active class on tabs immediately (no debounce). Used by selectSession(). */
@@ -1982,6 +2003,12 @@ class CodemanApp {
async selectSession(sessionId) { async selectSession(sessionId) {
if (this.activeSessionId === sessionId) return; 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 _selStart = performance.now();
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8); const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
_crashDiag.log(`SELECT: ${_selName}`); _crashDiag.log(`SELECT: ${_selName}`);
@@ -2147,11 +2174,14 @@ class CodemanApp {
// canvas may be at stale dimensions — content would render at wrong width. // canvas may be at stale dimensions — content would render at wrong width.
if (this.fitAddon) this.fitAddon.fit(); if (this.fitAddon) this.fitAddon.fit();
// Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls. // Instant cache restore for IDLE sessions only.
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread // For busy sessions, the cache is always stale — writing it first causes a
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously. // 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); 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`); _crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
this.terminal.clear(); this.terminal.clear();
this.terminal.reset(); this.terminal.reset();
@@ -2159,6 +2189,11 @@ class CodemanApp {
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; } if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
this.terminal.scrollToBottom(); this.terminal.scrollToBottom();
_crashDiag.log('CACHE_DONE'); _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'); _crashDiag.log('FETCH_START');
@@ -2200,9 +2235,9 @@ class CodemanApp {
this.terminal.reset(); this.terminal.reset();
} }
// Buffer load complete — unblock live SSE writes and flush any queued events. // Buffer load complete — unblock live SSE writes (queued events are discarded
// chunkedTerminalWrite calls _finishBufferLoad internally, but if we skipped // to prevent duplicate content). chunkedTerminalWrite calls _finishBufferLoad
// the chunked write (small buffer, cache hit, or empty), we must call it here. // internally, but if we skipped the write (cache hit or empty), call it here.
if (this._isLoadingBuffer) { if (this._isLoadingBuffer) {
this._finishBufferLoad(); this._finishBufferLoad();
} }
@@ -2226,9 +2261,19 @@ class CodemanApp {
}); });
} }
// Fire-and-forget resize — don't await to avoid blocking UI. // Fire-and-forget resize + Ctrl+L to force Ink redraw.
// The resize triggers an Ink redraw in Claude which streams back via SSE. // Tailed buffers accumulate stale CUP-positioned Ink frames that overlap
this.sendResize(sessionId); // 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 // Defer secondary panel updates so they don't block the main thread
// after terminal content is already visible. // after terminal content is already visible.
+10
View File
@@ -871,6 +871,16 @@
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </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 --> <!-- Header Displays Section -->
<div class="settings-section-header">Header Displays</div> <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). * Always-visible textarea below the terminal (in index.html).
* The browser handles IME composition natively — we just read * 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. * While this textarea has focus, window.cjkActive = true blocks xterm's onData.
* Arrow keys and function keys are forwarded to PTY directly. * 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) * @dependency index.html (#cjkInput textarea)
* @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData * @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 * @loadorder 5.5 of 15 — loaded after keyboard-accessory.js, before app.js
@@ -17,10 +43,12 @@ const CjkInput = (() => {
let _textarea = null; let _textarea = null;
let _send = null; let _send = null;
let _initialized = false; let _initialized = false;
let _onMousedown = null; let _composing = false;
let _onFocus = null; const _listeners = {};
let _onBlur = null;
let _onKeydown = null; // 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 = { const PASSTHROUGH_KEYS = {
ArrowUp: '\x1b[A', ArrowUp: '\x1b[A',
@@ -36,66 +64,176 @@ const CjkInput = (() => {
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05', 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 { return {
init({ send }) { init({ send }) {
// Guard against double-init: remove previous listeners
if (_initialized) this.destroy(); if (_initialized) this.destroy();
_send = send; _send = send;
_composing = false;
_textarea = document.getElementById('cjkInput'); _textarea = document.getElementById('cjkInput');
if (!_textarea) return this; if (!_textarea) return this;
_onMousedown = (e) => { e.stopPropagation(); }; // Seed the phantom character
_onFocus = () => { window.cjkActive = true; }; _resetToPhantom();
_onBlur = () => { window.cjkActive = false; };
_textarea.addEventListener('mousedown', _onMousedown);
_textarea.addEventListener('focus', _onFocus);
_textarea.addEventListener('blur', _onBlur);
_onKeydown = (e) => { _listeners.mousedown = (e) => { e.stopPropagation(); };
if (e.isComposing || e.keyCode === 229) return; _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') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (_textarea.value) { _composing = false;
_send(_textarea.value + '\r'); const val = _strip(_textarea.value);
_textarea.value = ''; if (val) {
_send(val + '\r');
} else { } else {
_send('\r'); _send('\r');
} }
_resetToPhantom();
return; return;
} }
// Escape: clear textarea // Escape: clear textarea (always works)
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
_textarea.value = ''; _composing = false;
_resetToPhantom();
return; return;
} }
// Ctrl combos: forward to PTY // Ctrl combos: forward to PTY (always works)
if (e.ctrlKey && CTRL_KEYS[e.key]) { if (e.ctrlKey && CTRL_KEYS[e.key]) {
e.preventDefault(); e.preventDefault();
_send(CTRL_KEYS[e.key]); _send(CTRL_KEYS[e.key]);
return; return;
} }
// Backspace: delete from textarea if has text, else forward to PTY // Below: only when NOT composing (composing keystrokes belong to IME)
if (e.key === 'Backspace' && !_textarea.value) { 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(); e.preventDefault();
_send('\x7f'); _send('\x7f');
_resetToPhantom();
return; return;
} }
// Arrow/function keys: forward to PTY when textarea is empty // Arrow/function keys: forward to PTY when no real text
if (PASSTHROUGH_KEYS[e.key] && !_textarea.value) { if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
e.preventDefault(); e.preventDefault();
_send(PASSTHROUGH_KEYS[e.key]); _send(PASSTHROUGH_KEYS[e.key]);
return; 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; _initialized = true;
return this; return this;
@@ -103,13 +241,13 @@ const CjkInput = (() => {
destroy() { destroy() {
if (_textarea) { if (_textarea) {
if (_onMousedown) _textarea.removeEventListener('mousedown', _onMousedown); for (const [event, handler] of Object.entries(_listeners)) {
if (_onFocus) _textarea.removeEventListener('focus', _onFocus); if (handler) _textarea.removeEventListener(event, handler);
if (_onBlur) _textarea.removeEventListener('blur', _onBlur); }
if (_onKeydown) _textarea.removeEventListener('keydown', _onKeydown);
} }
window.cjkActive = false; window.cjkActive = false;
_onMousedown = _onFocus = _onBlur = _onKeydown = null; _composing = false;
for (const key of Object.keys(_listeners)) delete _listeners[key];
_initialized = false; _initialized = false;
}, },
+1
View File
@@ -1147,6 +1147,7 @@ html.mobile-init .file-browser-panel {
/* Compact welcome overlay for mobile */ /* Compact welcome overlay for mobile */
.welcome-content { .welcome-content {
max-width: calc(100vw - 1.5rem);
padding: 1rem 0.75rem; padding: 1rem 0.75rem;
} }
+2 -14
View File
@@ -613,13 +613,7 @@ Object.assign(CodemanApp.prototype, {
}, },
renderTaskPanel() { renderTaskPanel() {
// Debounce renders at 100ms to prevent excessive DOM updates this._debouncedCall('taskPanel', this._renderTaskPanelImmediate);
if (this.renderTaskPanelTimeout) {
clearTimeout(this.renderTaskPanelTimeout);
}
this.renderTaskPanelTimeout = setTimeout(() => {
this._renderTaskPanelImmediate();
}, 100);
}, },
_renderTaskPanelImmediate() { _renderTaskPanelImmediate() {
@@ -2974,13 +2968,7 @@ Object.assign(CodemanApp.prototype, {
renderMuxSessions() { renderMuxSessions() {
// Debounce renders at 100ms to prevent excessive DOM updates this._debouncedCall('muxSessions', this._renderMuxSessionsImmediate);
if (this.renderMuxSessionsTimeout) {
clearTimeout(this.renderMuxSessionsTimeout);
}
this.renderMuxSessionsTimeout = setTimeout(() => {
this._renderMuxSessionsImmediate();
}, 100);
}, },
_renderMuxSessionsImmediate() { _renderMuxSessionsImmediate() {
+1 -7
View File
@@ -471,13 +471,7 @@ Object.assign(CodemanApp.prototype, {
}, },
renderRalphStatePanel() { renderRalphStatePanel() {
// Debounce renders at 50ms to prevent excessive DOM updates this._debouncedCall('ralphStatePanel', this._renderRalphStatePanelImmediate, 50);
if (this.renderRalphStatePanelTimeout) {
clearTimeout(this.renderRalphStatePanelTimeout);
}
this.renderRalphStatePanelTimeout = setTimeout(() => {
this._renderRalphStatePanelImmediate();
}, 50);
}, },
_renderRalphStatePanelImmediate() { _renderRalphStatePanelImmediate() {
+7
View File
@@ -273,6 +273,11 @@ Object.assign(CodemanApp.prototype, {
this.terminal.clear(); this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`); this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`);
this.terminal.writeln(''); 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 { try {
// Get case path first // Get case path first
@@ -493,6 +498,8 @@ Object.assign(CodemanApp.prototype, {
this.terminal.clear(); this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`); this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
this.terminal.writeln(''); this.terminal.writeln('');
// Focus in sync gesture context (see runClaude comment)
this.terminal.focus();
try { try {
// Check if OpenCode is available // Check if OpenCode is available
+8 -3
View File
@@ -364,6 +364,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus(); this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
// Claude CLI settings // Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
@@ -1180,6 +1181,7 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked, imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked, tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
// Claude CLI settings // Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value, claudeMode: document.getElementById('appSettingsClaudeMode').value,
@@ -1296,9 +1298,12 @@ Object.assign(CodemanApp.prototype, {
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
this.updateSubagentWindowVisibility(); // Apply subagent window 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) // Save to server (includes notification prefs for cross-browser persistence)
// Strip device-specific keys — localEchoEnabled is per-platform (touch default differs) // Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
const { localEchoEnabled: _leo, ...serverSettings } = settings; const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, ...serverSettings } = settings;
try { try {
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings }); await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
@@ -1682,7 +1687,7 @@ Object.assign(CodemanApp.prototype, {
const displayKeys = new Set([ const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost', 'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled',
]); ]);
// Merge settings: non-display keys always sync from server, // Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value // display keys only seed from server when localStorage has no value
+13 -1
View File
@@ -94,7 +94,11 @@ textarea:focus-visible {
outline: none; outline: none;
} }
/* xterm.js hidden textarea — must never show focus ring or caret */ /* xterm.js hidden textarea — must never show focus ring or caret.
* On mobile (touch devices), override xterm.css defaults that position the
* textarea at left:-9999em with width/height:0. iOS Safari ignores keyboard
* input to off-screen zero-size textareas, so we move it on-screen with a
* minimal size while keeping it visually invisible. */
.xterm-helper-textarea { .xterm-helper-textarea {
border: none !important; border: none !important;
box-shadow: none !important; box-shadow: none !important;
@@ -102,6 +106,14 @@ textarea:focus-visible {
opacity: 0 !important; opacity: 0 !important;
caret-color: transparent !important; caret-color: transparent !important;
} }
.touch-device .xterm .xterm-helper-textarea {
left: 0 !important;
top: 0 !important;
width: 1px !important;
height: 1px !important;
z-index: -1 !important;
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
}
/* Session tab focus */ /* Session tab focus */
.session-tab:focus-visible { .session-tab:focus-visible {
+221 -135
View File
@@ -64,7 +64,9 @@ Object.assign(CodemanApp.prototype, {
const unicode11Addon = new Unicode11Addon.Unicode11Addon(); const unicode11Addon = new Unicode11Addon.Unicode11Addon();
this.terminal.loadAddon(unicode11Addon); this.terminal.loadAddon(unicode11Addon);
this.terminal.unicode.activeVersion = '11'; 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'); const container = document.getElementById('terminalContainer');
@@ -115,8 +117,8 @@ Object.assign(CodemanApp.prototype, {
// On mobile Safari, delay initial fit() to allow layout to settle // On mobile Safari, delay initial fit() to allow layout to settle
// This prevents 0-column terminals caused by fit() running before container is sized // This prevents 0-column terminals caused by fit() running before container is sized
const isMobileSafari = MobileDetection.getDeviceType() === 'mobile' && const isMobileSafari =
document.body.classList.contains('safari-browser'); MobileDetection.getDeviceType() === 'mobile' && document.body.classList.contains('safari-browser');
if (isMobileSafari) { if (isMobileSafari) {
// Wait for layout, then fit multiple times to ensure proper sizing // Wait for layout, then fit multiple times to ensure proper sizing
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -133,11 +135,15 @@ Object.assign(CodemanApp.prototype, {
// Always use mouse wheel for terminal scrollback, never forward to application. // 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. // Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation.
container.addEventListener('wheel', (ev) => { container.addEventListener(
ev.preventDefault(); 'wheel',
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1); (ev) => {
this.terminal.scrollLines(lines); ev.preventDefault();
}, { passive: false }); 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. // Touch scrolling — use terminal.scrollLines() for all devices.
// xterm.js DOM renderer doesn't populate xterm-viewport's scroll area, // 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() // has nothing to scroll. Instead, convert touch deltas into scrollLines()
// calls, matching the wheel handler above. // calls, matching the wheel handler above.
{ {
const cellHeight = () => const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
let touchLastY = 0; let touchLastY = 0;
let velocity = 0; let velocity = 0;
let lastTime = 0; let lastTime = 0;
@@ -174,46 +179,65 @@ Object.assign(CodemanApp.prototype, {
// Accumulate sub-line pixel deltas so slow swipes still scroll // Accumulate sub-line pixel deltas so slow swipes still scroll
let pixelAccum = 0; let pixelAccum = 0;
container.addEventListener('touchstart', (ev) => { container.addEventListener(
if (ev.touches.length === 1) { 'touchstart',
touchLastY = ev.touches[0].clientY; (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; velocity = 0;
pixelAccum = 0; pixelAccum = 0;
isTouching = true; },
lastTime = 0; { passive: true }
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 });
} }
// Welcome message // Welcome message
@@ -267,8 +291,13 @@ Object.assign(CodemanApp.prototype, {
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris, // Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw. // then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
if (activeResizeSession && activeResizeSession.mode !== 'shell' && !activeResizeSession._ended if (
&& this.terminal && this.isTerminalAtBottom()) { activeResizeSession &&
activeResizeSession.mode !== 'shell' &&
!activeResizeSession._ended &&
this.terminal &&
this.isTerminalAtBottom()
) {
this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
} }
// Skip server resize while mobile keyboard is visible — sending SIGWINCH // Skip server resize while mobile keyboard is visible — sending SIGWINCH
@@ -281,14 +310,12 @@ Object.assign(CodemanApp.prototype, {
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS; const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS; const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
// Only send resize if dimensions actually changed // Only send resize if dimensions actually changed
if (!this._lastResizeDims || if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
cols !== this._lastResizeDims.cols ||
rows !== this._lastResizeDims.rows) {
this._lastResizeDims = { cols, rows }; this._lastResizeDims = { cols, rows };
fetch(`/api/sessions/${this.activeSessionId}/resize`, { fetch(`/api/sessions/${this.activeSessionId}/resize`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows }) body: JSON.stringify({ cols, rows }),
}).catch(() => {}); }).catch(() => {});
} }
} }
@@ -442,7 +469,11 @@ Object.assign(CodemanApp.prototype, {
if (p) { if (p) {
const buf = this.terminal.buffer.active; const buf = this.terminal.buffer.active;
const line = buf.getLine(buf.viewportY + p.row); 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 {} } catch {}
this._tabCompletionBaseText = baseText; this._tabCompletionBaseText = baseText;
@@ -457,7 +488,8 @@ Object.assign(CodemanApp.prototype, {
const selfTab = this; const selfTab = this;
this._tabCompletionFallback = setTimeout(() => { this._tabCompletionFallback = setTimeout(() => {
selfTab._tabCompletionFallback = null; selfTab._tabCompletionFallback = null;
if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId) return; if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId)
return;
const ov = selfTab._localEchoOverlay; const ov = selfTab._localEchoOverlay;
if (!ov || ov.pendingText) return; if (!ov || ov.pendingText) return;
selfTab.terminal.write('', () => { selfTab.terminal.write('', () => {
@@ -556,7 +588,8 @@ Object.assign(CodemanApp.prototype, {
} }
const buffer = self.terminal.buffer.active; 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) { if (!line) {
callback(undefined); callback(undefined);
@@ -573,12 +606,37 @@ Object.assign(CodemanApp.prototype, {
const links = []; 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.) // Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
// Handles: tail -f /path, grep pattern /path, cat -n /path // 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; const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
// Pattern 2: Paths with common extensions // 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 // Pattern 3: Bash() tool output
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g; const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
@@ -588,27 +646,32 @@ Object.assign(CodemanApp.prototype, {
if (startCol === -1) return; if (startCol === -1) return;
// Skip if already have link at this position // 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({ links.push({
text: filePath, text: filePath,
range: { range: {
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
end: { x: startCol + filePath.length + 1, y: bufferLineNumber } end: { x: startCol + filePath.length + 1, y: bufferLineNumber },
}, },
decorations: { decorations: {
pointerCursor: true, pointerCursor: true,
underline: true underline: true,
}, },
activate(event, text) { activate(event, text) {
self.openLogViewerWindow(text, self.activeSessionId); self.openLogViewerWindow(text, self.activeSessionId);
} },
}); });
}; };
// Match all patterns // Match all patterns — URLs first so they take priority
let match; let match;
urlPattern.lastIndex = 0;
while ((match = urlPattern.exec(lineText)) !== null) {
addUrlLink(match[0], match.index);
}
cmdPattern.lastIndex = 0; cmdPattern.lastIndex = 0;
while ((match = cmdPattern.exec(lineText)) !== null) { while ((match = cmdPattern.exec(lineText)) !== null) {
addLink(match[2], match.index); addLink(match[2], match.index);
@@ -625,10 +688,13 @@ Object.assign(CodemanApp.prototype, {
} }
if (links.length > 0) { 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); callback(links.length > 0 ? links : undefined);
} },
}); });
console.log('[LinkProvider] File path link provider registered'); console.log('[LinkProvider] File path link provider registered');
@@ -694,12 +760,17 @@ Object.assign(CodemanApp.prototype, {
// Build DOM safely (no innerHTML with user data) // Build DOM safely (no innerHTML with user data)
list.replaceChildren(); list.replaceChildren();
for (const s of display) { for (const s of display) {
const size = s.sizeBytes < 1024 ? `${s.sizeBytes}B` const size =
: s.sizeBytes < 1048576 ? `${(s.sizeBytes / 1024).toFixed(0)}K` s.sizeBytes < 1024
: `${(s.sizeBytes / 1048576).toFixed(1)}M`; ? `${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 date = new Date(s.lastModified);
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) const timeStr =
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }); 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 shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
const item = document.createElement('div'); const item = document.createElement('div');
@@ -762,7 +833,7 @@ Object.assign(CodemanApp.prototype, {
const createRes = await fetch('/api/sessions', { const createRes = await fetch('/api/sessions', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId }) body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId }),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
if (!createData.success) throw new Error(createData.error); if (!createData.success) throw new Error(createData.error);
@@ -780,7 +851,6 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Terminal Rendering // Terminal Rendering
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -825,9 +895,10 @@ Object.assign(CodemanApp.prototype, {
// Opt-in flicker filter: buffer screen clear patterns (for sessions that enable it) // Opt-in flicker filter: buffer screen clear patterns (for sessions that enable it)
if (flickerFilterEnabled) { if (flickerFilterEnabled) {
const hasScreenClear = data.includes('\x1b[2J') || const hasScreenClear =
data.includes('\x1b[H\x1b[J') || data.includes('\x1b[2J') ||
(data.includes('\x1b[H') && data.includes('\x1b[?25l')); data.includes('\x1b[H\x1b[J') ||
(data.includes('\x1b[H') && data.includes('\x1b[?25l'));
if (hasScreenClear) { if (hasScreenClear) {
this.flickerFilterActive = true; this.flickerFilterActive = true;
@@ -893,47 +964,49 @@ Object.assign(CodemanApp.prototype, {
* Position is tracked dynamically by _findPrompt() on every render. * Position is tracked dynamically by _findPrompt() on every render.
*/ */
_updateLocalEchoState() { _updateLocalEchoState() {
const settings = this.loadAppSettingsFromStorage(); const settings = this.loadAppSettingsFromStorage();
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
const shouldEnable = !!(echoEnabled && session); const shouldEnable = !!(echoEnabled && session);
if (this._localEchoEnabled && !shouldEnable) { if (this._localEchoEnabled && !shouldEnable) {
this._localEchoOverlay?.clear(); this._localEchoOverlay?.clear();
} }
this._localEchoEnabled = shouldEnable; this._localEchoEnabled = shouldEnable;
// Swap prompt finder based on session mode // Swap prompt finder based on session mode
if (this._localEchoOverlay && session) { if (this._localEchoOverlay && session) {
if (session.mode === 'opencode') { if (session.mode === 'opencode') {
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row. // OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
// The input area is "┃ <text>" — the ┃ is the anchor, offset 3 skips "┃ ". // 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 ┃. // We use the cursor row (cursorY) to find the right line, then scan for ┃.
this._localEchoOverlay.setPrompt({ this._localEchoOverlay.setPrompt({
type: 'custom', type: 'custom',
offset: 3, offset: 3,
find: (terminal) => { find: (terminal) => {
try { try {
const buf = terminal.buffer.active; const buf = terminal.buffer.active;
const row = buf.cursorY; const row = buf.cursorY;
const line = buf.getLine(buf.viewportY + row); const line = buf.getLine(buf.viewportY + row);
if (!line) return null; if (!line) return null;
const text = line.translateToString(true); const text = line.translateToString(true);
const idx = text.indexOf('\u2503'); // ┃ (BOX DRAWINGS HEAVY VERTICAL) const idx = text.indexOf('\u2503'); // ┃ (BOX DRAWINGS HEAVY VERTICAL)
if (idx >= 0) return { row, col: idx }; if (idx >= 0) return { row, col: idx };
return null; return null;
} catch { 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. } else if (session.mode === 'shell') {
// Disable it by clearing any pending text. // Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
this._localEchoOverlay.clear(); // Disable it by clearing any pending text.
this._localEchoEnabled = false; this._localEchoOverlay.clear();
} else { this._localEchoEnabled = false;
// Claude Code: scan for ❯ prompt character } else {
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 }); // Claude Code: scan for ❯ prompt character
} this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
} }
}
}, },
/** /**
@@ -950,7 +1023,7 @@ Object.assign(CodemanApp.prototype, {
const joined = this.pendingWrites.join(''); const joined = this.pendingWrites.join('');
this.pendingWrites = []; this.pendingWrites = [];
const _joinedLen = joined.length; 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. // Per-frame byte budget to prevent main thread blocking.
// Large writes (141KB+) can freeze Chrome for 2+ minutes. // Large writes (141KB+) can freeze Chrome for 2+ minutes.
@@ -974,7 +1047,10 @@ Object.assign(CodemanApp.prototype, {
} }
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen; const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
const _dt = performance.now() - _t0; 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 // Sticky scroll: if user was at bottom, keep them there after new output
if (this._wasAtBottomBeforeWrite) { if (this._wasAtBottomBeforeWrite) {
@@ -991,8 +1067,12 @@ Object.assign(CodemanApp.prototype, {
// Use terminal.write('', callback) to defer detection until xterm.js // Use terminal.write('', callback) to defer detection until xterm.js
// finishes processing ALL queued writes — direct buffer reads after // finishes processing ALL queued writes — direct buffer reads after
// terminal.write(data) can miss text if xterm processes asynchronously. // terminal.write(data) can miss text if xterm processes asynchronously.
if (this._tabCompletionSessionId && this._tabCompletionSessionId === this.activeSessionId if (
&& this._localEchoOverlay && !this._localEchoOverlay.pendingText) { this._tabCompletionSessionId &&
this._tabCompletionSessionId === this.activeSessionId &&
this._localEchoOverlay &&
!this._localEchoOverlay.pendingText
) {
const overlay = this._localEchoOverlay; const overlay = this._localEchoOverlay;
const self = this; const self = this;
this.terminal.write('', () => { this.terminal.write('', () => {
@@ -1013,7 +1093,10 @@ Object.assign(CodemanApp.prototype, {
self._tabCompletionSessionId = null; self._tabCompletionSessionId = null;
self._tabCompletionRetries = 0; self._tabCompletionRetries = 0;
self._tabCompletionBaseText = null; self._tabCompletionBaseText = null;
if (self._tabCompletionFallback) { clearTimeout(self._tabCompletionFallback); self._tabCompletionFallback = null; } if (self._tabCompletionFallback) {
clearTimeout(self._tabCompletionFallback);
self._tabCompletionFallback = null;
}
overlay.rerender(); overlay.rerender();
} }
} else { } else {
@@ -1085,7 +1168,9 @@ Object.assign(CodemanApp.prototype, {
if (offset >= cleanBuffer.length) { if (offset >= cleanBuffer.length) {
const _totalMs = performance.now() - _chunkStart; const _totalMs = performance.now() - _chunkStart;
console.log(`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`); console.log(
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`
);
// Wait one more frame for xterm to finish rendering before resolving // Wait one more frame for xterm to finish rendering before resolving
requestAnimationFrame(finish); requestAnimationFrame(finish);
return; return;
@@ -1096,7 +1181,10 @@ Object.assign(CodemanApp.prototype, {
this.terminal.write(chunk); this.terminal.write(chunk);
const _cdt = performance.now() - _ct0; const _cdt = performance.now() - _ct0;
_chunkCount++; _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; offset += chunkSize;
// Schedule next chunk on next frame // Schedule next chunk on next frame
@@ -1109,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). * 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() { _finishBufferLoad() {
const queue = this._loadBufferQueue;
this._isLoadingBuffer = false; this._isLoadingBuffer = false;
this._loadBufferQueue = null; this._loadBufferQueue = null;
if (queue && queue.length > 0) {
for (const data of queue) {
this.batchTerminalWrite(data);
}
}
}, },
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Terminal Controls // Terminal Controls
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -1157,7 +1244,7 @@ Object.assign(CodemanApp.prototype, {
await fetch(`/api/sessions/${this.activeSessionId}/input`, { await fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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'); this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
@@ -1184,7 +1271,7 @@ Object.assign(CodemanApp.prototype, {
fetch(`/api/sessions/${sessionId}/input`, { fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' }) body: JSON.stringify({ input: '\x0c' }),
}); });
}); });
}, },
@@ -1246,7 +1333,7 @@ Object.assign(CodemanApp.prototype, {
if (!dims) return null; if (!dims) return null;
return { return {
cols: Math.max(dims.cols, MIN_COLS), cols: Math.max(dims.cols, MIN_COLS),
rows: Math.max(dims.rows, MIN_ROWS) rows: Math.max(dims.rows, MIN_ROWS),
}; };
}, },
@@ -1273,7 +1360,7 @@ Object.assign(CodemanApp.prototype, {
await fetch(`/api/sessions/${sessionId}/resize`, { await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dims) body: JSON.stringify(dims),
}); });
}, },
@@ -1287,11 +1374,10 @@ Object.assign(CodemanApp.prototype, {
await fetch(`/api/sessions/${this.activeSessionId}/input`, { await fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, useMux: true }) body: JSON.stringify({ input, useMux: true }),
}); });
}, },
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Directory Input // Directory Input
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
+26
View File
@@ -7,6 +7,7 @@
import { join, resolve, relative, isAbsolute } from 'node:path'; import { join, resolve, relative, isAbsolute } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import type { z } from 'zod';
import { Session } from '../session.js'; import { Session } from '../session.js';
import { ApiErrorCode, createErrorResponse } from '../types.js'; import { ApiErrorCode, createErrorResponse } from '../types.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
@@ -50,6 +51,31 @@ export function findSessionOrFail(ctx: SessionPort, sessionId: string): Session
return session; return session;
} }
/**
* Parse and validate a request body against a Zod schema, or throw a structured 400 error.
* Replaces the repeated pattern: `const r = Schema.safeParse(body); if (!r.success) return createErrorResponse(...)`.
*/
export function parseBody<T>(schema: z.ZodType<T>, body: unknown, errorMessage?: string): T {
const result = schema.safeParse(body);
if (!result.success) {
const msg = errorMessage ?? result.error.issues[0]?.message ?? 'Validation failed';
throw Object.assign(new Error(msg), {
statusCode: 400,
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, msg),
});
}
return result.data;
}
/**
* Persist session state and broadcast a SessionUpdated event.
* Replaces the repeated two-line pattern across route handlers.
*/
export function persistAndBroadcastSession(ctx: SessionPort & EventPort, session: Session): void {
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
}
/** /**
* Formats uptime in seconds to a human-readable string (e.g., "1d 2h 30m 15s"). * Formats uptime in seconds to a human-readable string (e.g., "1d 2h 30m 15s").
*/ */
+3 -11
View File
@@ -14,7 +14,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js'; import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
import { generateClaudeMd } from '../../templates/claude-md.js'; import { generateClaudeMd } from '../../templates/claude-md.js';
import { writeHooksConfig } from '../../hooks-config.js'; import { writeHooksConfig } from '../../hooks-config.js';
import { CASES_DIR, validatePathWithinBase } from '../route-helpers.js'; import { CASES_DIR, validatePathWithinBase, parseBody } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js'; import type { EventPort, ConfigPort } from '../ports/index.js';
@@ -83,11 +83,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}); });
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => { app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const result = CreateCaseSchema.safeParse(req.body); const { name, description } = parseBody(CreateCaseSchema, req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { name, description } = result.data;
const casePath = validatePathWithinBase(name, CASES_DIR); const casePath = validatePathWithinBase(name, CASES_DIR);
if (!casePath) { if (!casePath) {
@@ -120,11 +116,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// Link an existing folder as a case // Link an existing folder as a case
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => { app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const lcResult = LinkCaseSchema.safeParse(req.body); const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
if (!lcResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { name, path: folderPath } = lcResult.data;
// Expand ~ to home directory // Expand ~ to home directory
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath; const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
+2 -6
View File
@@ -7,7 +7,7 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse } from '../../types.js'; import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { HookEventSchema, isValidWorkingDir } from '../schemas.js'; import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
import { sanitizeHookData } from '../route-helpers.js'; import { sanitizeHookData, parseBody } from '../route-helpers.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
export function registerHookEventRoutes( export function registerHookEventRoutes(
@@ -15,11 +15,7 @@ export function registerHookEventRoutes(
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
): void { ): void {
app.post('/api/hook-event', async (req) => { app.post('/api/hook-event', async (req) => {
const result = HookEventSchema.safeParse(req.body); const { event, sessionId, data } = parseBody(HookEventSchema, req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { event, sessionId, data } = result.data;
if (!ctx.sessions.has(sessionId)) { if (!ctx.sessions.has(sessionId)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
} }
+4 -11
View File
@@ -19,6 +19,7 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js'; import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
import type { EventPort, OrchestratorPort } from '../ports/index.js'; import type { EventPort, OrchestratorPort } from '../ports/index.js';
@@ -85,12 +86,7 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/start', async (req) => { app.post('/api/orchestrator/start', async (req) => {
const parsed = OrchestratorStartSchema.safeParse(req.body); const { goal, config } = parseBody(OrchestratorStartSchema, req.body, 'Invalid request body');
if (!parsed.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { goal, config } = parsed.data;
// Initialize loop if needed // Initialize loop if needed
let loop = ctx.orchestratorLoop; let loop = ctx.orchestratorLoop;
@@ -141,13 +137,10 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
app.post('/api/orchestrator/reject', async (req) => { app.post('/api/orchestrator/reject', async (req) => {
const loop = getLoop(); const loop = getLoop();
const parsed = OrchestratorRejectSchema.safeParse(req.body); const { feedback } = parseBody(OrchestratorRejectSchema, req.body, 'Feedback is required');
if (!parsed.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Feedback is required');
}
try { try {
loop.reject(parsed.data.feedback).catch((err) => { loop.reject(feedback).catch((err) => {
console.error('[Orchestrator Route] Reject failed:', getErrorMessage(err)); console.error('[Orchestrator Route] Reject failed:', getErrorMessage(err));
}); });
return { ok: true, state: loop.state }; return { ok: true, state: loop.state };
+10 -26
View File
@@ -17,7 +17,7 @@ import {
PlanTaskUpdateSchema, PlanTaskUpdateSchema,
PlanTaskAddSchema, PlanTaskAddSchema,
} from '../schemas.js'; } from '../schemas.js';
import { findSessionOrFail, CASES_DIR, validatePathWithinBase } from '../route-helpers.js'; import { findSessionOrFail, parseBody, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
@@ -29,11 +29,11 @@ export function registerPlanRoutes(app: FastifyInstance, ctx: SessionPort & Even
// ========== Generate Plan (Simple) ========== // ========== Generate Plan (Simple) ==========
app.post('/api/generate-plan', async (req): Promise<ApiResponse> => { app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
const gpResult = GeneratePlanSchema.safeParse(req.body); const { taskDescription, detailLevel = 'standard' } = parseBody(
if (!gpResult.success) { GeneratePlanSchema,
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); req.body,
} 'Invalid request body'
const { taskDescription, detailLevel = 'standard' } = gpResult.data; );
// Build sophisticated prompt based on Ralph Wiggum methodology // Build sophisticated prompt based on Ralph Wiggum methodology
const detailConfig = { const detailConfig = {
@@ -223,11 +223,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// ========== Generate Plan (Detailed Orchestration) ========== // ========== Generate Plan (Detailed Orchestration) ==========
app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => { app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => {
const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body); const { taskDescription, caseName } = parseBody(GeneratePlanDetailedSchema, req.body, 'Invalid request body');
if (!gpdResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { taskDescription, caseName } = gpdResult.data;
// Determine output directory for saving wizard results // Determine output directory for saving wizard results
let outputDir: string | undefined; let outputDir: string | undefined;
@@ -327,11 +323,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// ========== Cancel Plan Generation ========== // ========== Cancel Plan Generation ==========
app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => { app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => {
const cpResult = CancelPlanSchema.safeParse(req.body); const { orchestratorId } = parseBody(CancelPlanSchema, req.body, 'Invalid request body');
if (!cpResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { orchestratorId } = cpResult.data;
// If specific orchestrator ID provided, cancel just that one // If specific orchestrator ID provided, cancel just that one
if (orchestratorId) { if (orchestratorId) {
@@ -374,11 +366,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
} }
const ptuResult = PlanTaskUpdateSchema.safeParse(req.body); const update = parseBody(PlanTaskUpdateSchema, req.body, 'Invalid request body') as {
if (!ptuResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const update = ptuResult.data as {
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'; status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked';
error?: string; error?: string;
incrementAttempts?: boolean; incrementAttempts?: boolean;
@@ -454,11 +442,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
} }
const ptaResult = PlanTaskAddSchema.safeParse(req.body); const task = parseBody(PlanTaskAddSchema, req.body, 'Invalid request body');
if (!ptaResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const task = ptaResult.data;
const result = tracker.addPlanTask(task); const result = tracker.addPlanTask(task);
ctx.broadcast(SseEvent.SessionPlanTaskAdded, { sessionId: id, task: result.task }); ctx.broadcast(SseEvent.SessionPlanTaskAdded, { sessionId: id, task: result.task });
+4 -10
View File
@@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { ApiErrorCode, createErrorResponse } from '../../types.js'; import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { PushSubscribeSchema, PushPreferencesUpdateSchema } from '../schemas.js'; import { PushSubscribeSchema, PushPreferencesUpdateSchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import type { InfraPort } from '../ports/index.js'; import type { InfraPort } from '../ports/index.js';
export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void { export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
@@ -15,11 +16,7 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
}); });
app.post('/api/push/subscribe', async (req) => { app.post('/api/push/subscribe', async (req) => {
const result = PushSubscribeSchema.safeParse(req.body); const { endpoint, keys, userAgent, pushPreferences } = parseBody(PushSubscribeSchema, req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { endpoint, keys, userAgent, pushPreferences } = result.data;
const record = ctx.pushStore.addSubscription({ const record = ctx.pushStore.addSubscription({
id: uuidv4(), id: uuidv4(),
endpoint, endpoint,
@@ -33,11 +30,8 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
app.put('/api/push/subscribe/:id', async (req) => { app.put('/api/push/subscribe/:id', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const result = PushPreferencesUpdateSchema.safeParse(req.body); const { pushPreferences } = parseBody(PushPreferencesUpdateSchema, req.body);
if (!result.success) { const updated = ctx.pushStore.updatePreferences(id, pushPreferences);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const updated = ctx.pushStore.updatePreferences(id, result.data.pushPreferences);
if (!updated) { if (!updated) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found'); return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
} }
+20 -61
View File
@@ -13,7 +13,7 @@ import { Session } from '../../session.js';
import { RespawnController } from '../../respawn-controller.js'; import { RespawnController } from '../../respawn-controller.js';
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js'; import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js'; import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
import { writeHooksConfig } from '../../hooks-config.js'; import { writeHooksConfig } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js'; import { generateClaudeMd } from '../../templates/claude-md.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js';
@@ -31,22 +31,18 @@ export function registerRalphRoutes(
// Configure Ralph tracker for a session // Configure Ralph tracker for a session
app.post('/api/sessions/:id/ralph-config', async (req) => { app.post('/api/sessions/:id/ralph-config', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const ralphResult = RalphConfigSchema.safeParse(req.body); const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = parseBody(
if (!ralphResult.success) { RalphConfigSchema,
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); req.body,
} 'Invalid request body'
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data as { ) as {
enabled?: boolean; enabled?: boolean;
completionPhrase?: string; completionPhrase?: string;
maxIterations?: number; maxIterations?: number;
reset?: boolean | 'full'; reset?: boolean | 'full';
disableAutoEnable?: boolean; disableAutoEnable?: boolean;
}; };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
// Ralph tracker is not supported for opencode sessions // Ralph tracker is not supported for opencode sessions
if (session.mode === 'opencode') { if (session.mode === 'opencode') {
@@ -111,11 +107,7 @@ export function registerRalphRoutes(
// Reset circuit breaker for Ralph tracker // Reset circuit breaker for Ralph tracker
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => { app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
session.ralphTracker.resetCircuitBreaker(); session.ralphTracker.resetCircuitBreaker();
return { success: true }; return { success: true };
@@ -124,11 +116,7 @@ export function registerRalphRoutes(
// Get Ralph status block and circuit breaker state // Get Ralph status block and circuit breaker state
app.get('/api/sessions/:id/ralph-status', async (req) => { app.get('/api/sessions/:id/ralph-status', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return { return {
success: true, success: true,
@@ -148,11 +136,7 @@ export function registerRalphRoutes(
// Generate @fix_plan.md content from todos // Generate @fix_plan.md content from todos
app.get('/api/sessions/:id/fix-plan', async (req) => { app.get('/api/sessions/:id/fix-plan', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const content = session.ralphTracker.generateFixPlanMarkdown(); const content = session.ralphTracker.generateFixPlanMarkdown();
return { return {
@@ -167,16 +151,8 @@ export function registerRalphRoutes(
// Import todos from @fix_plan.md content // Import todos from @fix_plan.md content
app.post('/api/sessions/:id/fix-plan/import', async (req) => { app.post('/api/sessions/:id/fix-plan/import', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const importResult = FixPlanImportSchema.safeParse(req.body); const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body');
if (!importResult.success) { const session = findSessionOrFail(ctx, id);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { content } = importResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const importedCount = session.ralphTracker.importFixPlanMarkdown(content); const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
ctx.persistSessionState(session); ctx.persistSessionState(session);
@@ -193,11 +169,7 @@ export function registerRalphRoutes(
// Write @fix_plan.md to session's working directory // Write @fix_plan.md to session's working directory
app.post('/api/sessions/:id/fix-plan/write', async (req) => { app.post('/api/sessions/:id/fix-plan/write', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const workingDir = session.workingDir; const workingDir = session.workingDir;
if (!workingDir) { if (!workingDir) {
@@ -224,11 +196,7 @@ export function registerRalphRoutes(
// Read @fix_plan.md from session's working directory and import // Read @fix_plan.md from session's working directory and import
app.post('/api/sessions/:id/fix-plan/read', async (req) => { app.post('/api/sessions/:id/fix-plan/read', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const workingDir = session.workingDir; const workingDir = session.workingDir;
if (!workingDir) { if (!workingDir) {
@@ -266,16 +234,8 @@ export function registerRalphRoutes(
// This avoids mux input escaping issues with long multi-line prompts // This avoids mux input escaping issues with long multi-line prompts
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => { app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const promptResult = RalphPromptWriteSchema.safeParse(req.body); const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
if (!promptResult.success) { const session = findSessionOrFail(ctx, id);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { content } = promptResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const workingDir = session.workingDir; const workingDir = session.workingDir;
if (!workingDir) { if (!workingDir) {
@@ -308,11 +268,10 @@ export function registerRalphRoutes(
); );
} }
const rlResult = RalphLoopStartSchema.safeParse(req.body); const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = parseBody(
if (!rlResult.success) { RalphLoopStartSchema,
return createErrorResponse(ApiErrorCode.INVALID_INPUT, rlResult.error.issues[0]?.message ?? 'Validation failed'); req.body
} );
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data;
const casePath = join(CASES_DIR, caseName); const casePath = join(CASES_DIR, caseName);
+3 -11
View File
@@ -8,7 +8,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespa
import { RespawnController, type RespawnConfig } from '../../respawn-controller.js'; import { RespawnController, type RespawnConfig } from '../../respawn-controller.js';
import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js'; import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
import { findSessionOrFail, autoConfigureRalph } from '../route-helpers.js'; import { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { import {
@@ -84,11 +84,7 @@ export function registerRespawnRoutes(
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
let body: Partial<RespawnConfig> | undefined; let body: Partial<RespawnConfig> | undefined;
if (req.body) { if (req.body) {
const result = RespawnConfigSchema.safeParse(req.body); body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config');
}
body = result.data as Partial<RespawnConfig>;
} }
const session = findSessionOrFail(ctx, id); const session = findSessionOrFail(ctx, id);
@@ -162,11 +158,7 @@ export function registerRespawnRoutes(
app.put('/api/sessions/:id/respawn/config', async (req) => { app.put('/api/sessions/:id/respawn/config', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
// Validate respawn config to prevent arbitrary field injection // Validate respawn config to prevent arbitrary field injection
const parseResult = RespawnConfigSchema.safeParse(req.body); const config = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
if (!parseResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`);
}
const config = parseResult.data as Partial<RespawnConfig>;
const session = findSessionOrFail(ctx, id); const session = findSessionOrFail(ctx, id);
const controller = ctx.respawnControllers.get(id); const controller = ctx.respawnControllers.get(id);
+2 -5
View File
@@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify';
import { statSync } from 'node:fs'; import { statSync } from 'node:fs';
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
import { ScheduledRunSchema } from '../schemas.js'; import { ScheduledRunSchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js'; import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js';
export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void { export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void {
@@ -15,11 +16,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
}); });
app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse<never>> => { app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse<never>> => {
const srResult = ScheduledRunSchema.safeParse(req.body); const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body');
if (!srResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { prompt, workingDir, durationMinutes } = srResult.data;
// Validate workingDir exists and is a directory // Validate workingDir exists and is a directory
if (workingDir) { if (workingDir) {
+43 -156
View File
@@ -32,7 +32,15 @@ import {
QuickRunSchema, QuickRunSchema,
QuickStartSchema, QuickStartSchema,
} from '../schemas.js'; } from '../schemas.js';
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, validatePathWithinBase } from '../route-helpers.js'; import {
autoConfigureRalph,
CASES_DIR,
findSessionOrFail,
parseBody,
persistAndBroadcastSession,
SETTINGS_PATH,
validatePathWithinBase,
} from '../route-helpers.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js'; import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js'; import { generateClaudeMd } from '../../templates/claude-md.js';
@@ -138,11 +146,7 @@ export function registerSessionRoutes(
); );
} }
const result = CreateSessionSchema.safeParse(req.body); const body = parseBody(CreateSessionSchema, req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const body = result.data;
const workingDir = body.workingDir || process.cwd(); const workingDir = body.workingDir || process.cwd();
// Validate workingDir exists and is a directory // Validate workingDir exists and is a directory
@@ -210,23 +214,14 @@ export function registerSessionRoutes(
app.put('/api/sessions/:id/name', async (req) => { app.put('/api/sessions/:id/name', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const result = SessionNameSchema.safeParse(req.body); const body = parseBody(SessionNameSchema, req.body, 'Invalid request body');
if (!result.success) { const session = findSessionOrFail(ctx, id);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH); const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
session.name = name; session.name = name;
// Also update the mux session name if applicable // Also update the mux session name if applicable
ctx.mux.updateSessionName(id, session.name); ctx.mux.updateSessionName(id, session.name);
ctx.persistSessionState(session); persistAndBroadcastSession(ctx, session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
return { success: true, name: session.name }; return { success: true, name: session.name };
}); });
@@ -234,16 +229,8 @@ export function registerSessionRoutes(
app.put('/api/sessions/:id/color', async (req) => { app.put('/api/sessions/:id/color', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const result = SessionColorSchema.safeParse(req.body); const body = parseBody(SessionColorSchema, req.body, 'Invalid request body');
if (!result.success) { const session = findSessionOrFail(ctx, id);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink']; const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
if (!validColors.includes(body.color)) { if (!validColors.includes(body.color)) {
@@ -251,8 +238,7 @@ export function registerSessionRoutes(
} }
session.setColor(body.color as SessionColor); session.setColor(body.color as SessionColor);
ctx.persistSessionState(session); persistAndBroadcastSession(ctx, session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
return { success: true, color: session.color }; return { success: true, color: session.color };
}); });
@@ -291,11 +277,7 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id', async (req) => { app.get('/api/sessions/:id', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint. // Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard). // Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
@@ -310,11 +292,7 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id/output', async (req) => { app.get('/api/sessions/:id/output', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return { return {
success: true, success: true,
@@ -330,11 +308,7 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id/ralph-state', async (req) => { app.get('/api/sessions/:id/ralph-state', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return { return {
success: true, success: true,
@@ -350,11 +324,7 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id/run-summary', async (req) => { app.get('/api/sessions/:id/run-summary', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const tracker = ctx.runSummaryTrackers.get(id); const tracker = ctx.runSummaryTrackers.get(id);
if (!tracker) { if (!tracker) {
@@ -374,11 +344,7 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id/active-tools', async (req) => { app.get('/api/sessions/:id/active-tools', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return { return {
success: true, success: true,
@@ -396,16 +362,8 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => { app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const result = RunPromptSchema.safeParse(req.body); const { prompt } = parseBody(RunPromptSchema, req.body);
if (!result.success) { const session = findSessionOrFail(ctx, id);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { prompt } = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (session.isBusy()) { if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
@@ -424,11 +382,7 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => { app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (session.isBusy()) { if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
@@ -468,11 +422,7 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => { app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (session.isBusy()) { if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
@@ -502,16 +452,8 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => { app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const result = SessionInputWithLimitSchema.safeParse(req.body); const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body);
if (!result.success) { const session = findSessionOrFail(ctx, id);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { input, useMux } = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const inputStr = String(input); const inputStr = String(input);
if (inputStr.length > MAX_INPUT_LENGTH) { if (inputStr.length > MAX_INPUT_LENGTH) {
@@ -547,16 +489,8 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => { app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const result = ResizeSchema.safeParse(req.body); const { cols, rows } = parseBody(ResizeSchema, req.body);
if (!result.success) { const session = findSessionOrFail(ctx, id);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { cols, rows } = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
session.resize(cols, rows); session.resize(cols, rows);
return { success: true }; return { success: true };
@@ -569,11 +503,7 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id/terminal', async (req) => { app.get('/api/sessions/:id/terminal', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const query = req.query as { tail?: string }; const query = req.query as { tail?: string };
const session = ctx.sessions.get(id); const session = findSessionOrFail(ctx, id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0; const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const fullSize = session.terminalBufferLength; const fullSize = session.terminalBufferLength;
@@ -632,20 +562,11 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/auto-clear', async (req) => { app.post('/api/sessions/:id/auto-clear', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const acResult = AutoClearSchema.safeParse(req.body); const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
if (!acResult.success) { const session = findSessionOrFail(ctx, id);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = acResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
session.setAutoClear(body.enabled, body.threshold); session.setAutoClear(body.enabled, body.threshold);
ctx.persistSessionState(session); persistAndBroadcastSession(ctx, session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
return { return {
success: true, success: true,
@@ -662,20 +583,11 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/auto-compact', async (req) => { app.post('/api/sessions/:id/auto-compact', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const compactResult = AutoCompactSchema.safeParse(req.body); const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
if (!compactResult.success) { const session = findSessionOrFail(ctx, id);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = compactResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
session.setAutoCompact(body.enabled, body.threshold, body.prompt); session.setAutoCompact(body.enabled, body.threshold, body.prompt);
ctx.persistSessionState(session); persistAndBroadcastSession(ctx, session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
return { return {
success: true, success: true,
@@ -693,16 +605,8 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/image-watcher', async (req) => { app.post('/api/sessions/:id/image-watcher', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const iwResult = ImageWatcherSchema.safeParse(req.body); const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
if (!iwResult.success) { const session = findSessionOrFail(ctx, id);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = iwResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (body.enabled) { if (body.enabled) {
imageWatcher.watchSession(session.id, session.workingDir); imageWatcher.watchSession(session.id, session.workingDir);
@@ -726,20 +630,11 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/flicker-filter', async (req) => { app.post('/api/sessions/:id/flicker-filter', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const ffResult = FlickerFilterSchema.safeParse(req.body); const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
if (!ffResult.success) { const session = findSessionOrFail(ctx, id);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = ffResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
session.flickerFilterEnabled = body.enabled; session.flickerFilterEnabled = body.enabled;
ctx.persistSessionState(session); persistAndBroadcastSession(ctx, session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
return { return {
success: true, success: true,
@@ -764,11 +659,7 @@ export function registerSessionRoutes(
); );
} }
const qrResult = QuickRunSchema.safeParse(req.body); const { prompt, workingDir } = parseBody(QuickRunSchema, req.body, 'Invalid request body');
if (!qrResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { prompt, workingDir } = qrResult.data;
if (!prompt.trim()) { if (!prompt.trim()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required'); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
@@ -824,11 +715,7 @@ export function registerSessionRoutes(
); );
} }
const result = QuickStartSchema.safeParse(req.body); const { caseName = 'testcase', mode = 'claude', openCodeConfig } = parseBody(QuickStartSchema, req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data;
// Check OpenCode availability if requested // Check OpenCode availability if requested
if (mode === 'opencode') { if (mode === 'opencode') {
+8 -31
View File
@@ -24,7 +24,7 @@ import {
import { subagentWatcher } from '../../subagent-watcher.js'; import { subagentWatcher } from '../../subagent-watcher.js';
import { imageWatcher } from '../../image-watcher.js'; import { imageWatcher } from '../../image-watcher.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { findSessionOrFail, formatUptime, SETTINGS_PATH } from '../route-helpers.js'; import { findSessionOrFail, formatUptime, parseBody, SETTINGS_PATH } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
@@ -324,11 +324,8 @@ export function registerSystemRoutes(
}); });
app.put('/api/config', async (req) => { app.put('/api/config', async (req) => {
const parseResult = ConfigUpdateSchema.safeParse(req.body); const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
if (!parseResult.success) { ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`);
}
ctx.store.setConfig(parseResult.data as Partial<ReturnType<typeof ctx.store.getConfig>>);
return { success: true, config: ctx.store.getConfig() }; return { success: true, config: ctx.store.getConfig() };
}); });
@@ -408,11 +405,7 @@ export function registerSystemRoutes(
}); });
app.put('/api/settings', async (req) => { app.put('/api/settings', async (req) => {
const settingsResult = SettingsUpdateSchema.safeParse(req.body); const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
if (!settingsResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings');
}
const settings = settingsResult.data as Record<string, unknown>;
try { try {
const dir = dirname(SETTINGS_PATH); const dir = dirname(SETTINGS_PATH);
@@ -492,11 +485,7 @@ export function registerSystemRoutes(
}); });
app.put('/api/execution/model-config', async (req) => { app.put('/api/execution/model-config', async (req) => {
const mcResult = ModelConfigUpdateSchema.safeParse(req.body); const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
if (!mcResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config');
}
const modelConfig = mcResult.data as Record<string, unknown>;
try { try {
let existingSettings: Record<string, unknown> = {}; let existingSettings: Record<string, unknown> = {};
@@ -536,11 +525,7 @@ export function registerSystemRoutes(
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id); const session = findSessionOrFail(ctx, id);
const clResult = CpuLimitSchema.safeParse(req.body); const body = parseBody(CpuLimitSchema, req.body, 'Invalid request body') as Partial<NiceConfig>;
if (!clResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = clResult.data as Partial<NiceConfig>;
session.setNice(body); session.setNice(body);
ctx.persistSessionState(session); ctx.persistSessionState(session);
@@ -572,11 +557,7 @@ export function registerSystemRoutes(
}); });
app.put('/api/subagent-window-states', async (req) => { app.put('/api/subagent-window-states', async (req) => {
const swResult = SubagentWindowStatesSchema.safeParse(req.body); const states = parseBody(SubagentWindowStatesSchema, req.body, 'Invalid window states') as Record<string, unknown>;
if (!swResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states');
}
const states = swResult.data as Record<string, unknown>;
try { try {
const dir = dirname(windowStatesPath); const dir = dirname(windowStatesPath);
if (!existsSync(dir)) { if (!existsSync(dir)) {
@@ -604,11 +585,7 @@ export function registerSystemRoutes(
}); });
app.put('/api/subagent-parents', async (req) => { app.put('/api/subagent-parents', async (req) => {
const spResult = SubagentParentMapSchema.safeParse(req.body); const parentMap = parseBody(SubagentParentMapSchema, req.body, 'Invalid parent map');
if (!spResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map');
}
const parentMap = spResult.data;
try { try {
const dir = dirname(parentMapPath); const dir = dirname(parentMapPath);
if (!existsSync(dir)) { if (!existsSync(dir)) {