Compare commits

...
Author SHA1 Message Date
arkonandClaude Opus 4.8 e3c496e1a4 chore: version packages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:01:44 +02:00
arkonandClaude Opus 4.8 eb831487a0 feat(web): remove /compact button from mobile keyboard accessory bar
Drops /compact from both the simple and extended accessory-bar layouts,
the action handler (case folded back to clear-only), the refocus guard,
and the JSDoc. /clear retains its double-tap confirmation. Verified on a
touch-emulated viewport: neither layout renders a compact action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:00:38 +02:00
68594ac395 feat(web): response-viewer transcript fallback + code-block rendering (#102)
* feat(web): response-viewer transcript fallback + code-block rendering

- Add _cleanTerminalBuffer(): strip ANSI escapes and Claude CLI chrome
  (status bar, spinner, progress bar, prompt glyphs) from the terminal
  buffer so the response viewer renders clean text when the JSONL
  transcript is missing.
- Add _preprocessAsciiArt(): wrap box-drawing/block-element diagrams in
  fenced code blocks (narrow trigger that excludes arrows/geometric
  shapes common in prose) so marked.js preserves their whitespace.
- Extend .rv-text rules to .response-viewer-body so fallback-rendered
  content gets the same typography, code-block, and table styling.

* refactor(web): drop duplicate _cleanTerminalBuffer/_preprocessAsciiArt

These two methods already exist on master (added in #75). This branch
re-added byte-identical copies above _sanitizeHtml; in a JS class body the
later definition wins, so the duplicates were inert dead code. Remove them,
keeping only the genuinely new work: the _renderMarkdown null-safety fix and
the response-viewer CSS overhaul.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Co-authored-by: arkon <arkon.85@hotmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:43:52 +02:00
Tenggan ZhangandTeigen ec38fd11bf feat(web): mobile image upload to active session via paste dialog (#101)
- Extend the keyboard accessory paste dialog with an image picker
  (camera / photo library) plus best-effort image paste, routing
  selected files through the existing _uploadAndInsertImages pipeline
- Re-encode images to standard JPEG/PNG in the browser before upload,
  so mislabeled gallery images (e.g. MIUI WebP claiming image/jpeg)
  pass the server magic-byte check; PNG keeps transparency, GIF passes
  through untouched, decode failures fall back to the original file
- Log the real byte header on the paste-image magic-mismatch branch to
  pin down any remaining format mismatches without a guessing loop
- Ignore the runtime .claude-images/ upload directory

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-06-01 19:38:48 +02:00
Tenggan ZhangandTeigen 06f9ff6d9c fix: avoid event-loop stalls from synchronous tmux/ps calls (#100)
The stats collector (~2s) and mouse-mode sync (5s) ran execSync (pgrep/ps/
list-panes, 5s timeout each) per session on the server's single thread,
blocking the event loop. With several sessions or a momentarily slow tmux this
froze port 3000 for seconds-to-tens-of-seconds while the process stayed alive
and other ports were unaffected — self-healing, so it never restarted and the
60s loopback healthcheck missed it. Convert these hot-path calls to execAsync.

Also add an always-on event-loop lag monitor (utils/event-loop-monitor.ts) that
logs stalls >=1s to the web log, so this otherwise-invisible class of incident
leaves a quantified, timestamped trace.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-06-01 19:32:24 +02:00
15 changed files with 386 additions and 119 deletions

No files matched your search

+3
View File
@@ -66,3 +66,6 @@ commands
todo.md
@fix_plan.md
readme-preview.mjs
# Uploaded images land here under each session working dir (runtime artifact)
.claude-images/
+10
View File
@@ -1,5 +1,15 @@
# aicodeman
## 0.8.0
### Minor Changes
- Event-loop responsiveness fix, mobile image upload, response-viewer polish, and a mobile-UI trim.
- **fix: avoid event-loop stalls from synchronous tmux/ps calls (#100):** The session manager ran `execSync` for tmux mouse-mode toggles, `list-panes`, and `ps`/`pgrep` resource-stat queries on the main thread. Under multi-session / many-pane load these blocking spawns froze Node's single event loop, stalling SSE broadcasts and PTY I/O (the ":3000 briefly unreachable, process never restarts" class of incident). Converted those calls to async `execAsync` and updated all callers to `await`. Added a lightweight `utils/event-loop-monitor.ts` that samples loop-delay and logs when a stall threshold is exceeded, started on web-server boot and stopped on shutdown — so future regressions leave a timestamped, quantified log line instead of vanishing silently.
- **feat(web): mobile image upload to active session via paste dialog (#101):** The mobile keyboard-accessory paste dialog now attaches images, not just text — via a native picker (`accept=image/*` → camera / photo library / files) plus best-effort capture of images pasted into the textarea. Both paths reuse the existing `_uploadAndInsertImages()` → `POST /api/sessions/:id/paste-image` pipeline. Images are re-encoded client-side before upload (PNG→PNG to preserve transparency, everything else→JPEG, animated GIFs passed through untouched) so the bytes always match their declared extension — fixing the Android/MIUI case where a WebP/HEIF mislabeled as `image/jpeg` passed the extension allowlist but failed the server's magic-byte check. The server logs a precise diagnostic on any remaining magic-byte mismatch.
- **feat(web): response-viewer transcript fallback + code-block rendering (#102):** A substantial response-viewer styling overhaul — proportional prose font (monospace kept for code), refined heading/code/blockquote/list styling, readable max content width, and a smoother slide-in animation; the `.rv-text` rules now also apply to `.response-viewer-body` so transcript-missing fallback content gets the same typography. Plus a `_renderMarkdown` null-safety fix (`text` → `src = text || ''`).
- **feat(web): remove /compact button from the mobile keyboard accessory bar:** Dropped `/compact` from both the simple and extended accessory-bar layouts and the associated action handling. `/clear` retains its double-tap confirmation. Verified on a touch-emulated viewport that neither layout renders a compact action.
## 0.7.1
### Patch Changes
+1 -1
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.7.1 (must match `package.json`)
**Version**: 0.8.0 (must match `package.json`)
## Project Overview
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.7.1",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.7.1",
"version": "0.8.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.7.1",
"version": "0.8.0",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+44 -34
View File
@@ -1153,19 +1153,23 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
const psOutput = execSync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const psOutput = (
await execAsync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
})
).stdout.trim();
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
let childCount = 0;
try {
const childOutput = execSync(`pgrep -P ${session.pid} | wc -l`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const childOutput = (
await execAsync(`pgrep -P ${session.pid} | wc -l`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
})
).stdout.trim();
childCount = parseInt(childOutput, 10) || 0;
} catch {
// No children or command failed
@@ -1202,13 +1206,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Step 1: Get descendant PIDs
const descendantMap = new Map<number, number[]>();
const pgrepOutput = execSync(
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
).trim();
const pgrepOutput = (
await execAsync(
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
)
).stdout.trim();
for (const line of pgrepOutput.split('\n')) {
const [pidStr, childrenStr] = line.split(':');
@@ -1233,10 +1239,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Step 3: Single ps call
const pidArray = Array.from(allPids);
if (pidArray.length > 0) {
const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const psOutput = (
await execAsync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
})
).stdout.trim();
const processStats = new Map<number, { rss: number; cpu: number }>();
for (const line of psOutput.split('\n')) {
@@ -1324,11 +1332,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
clearInterval(this.mouseSyncInterval);
}
this.mouseSyncInterval = setInterval(() => {
this.mouseSyncInterval = setInterval(async () => {
if (IS_TEST_MODE) return;
for (const session of this.sessions.values()) {
const panes = this.listPanes(session.muxName);
const panes = await this.listPanes(session.muxName);
const count = panes.length;
if (count === 0) continue;
@@ -1337,12 +1345,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Pane count changed — toggle mouse mode
if (count > 1) {
if (this.enableMouseMode(session.muxName)) {
if (await this.enableMouseMode(session.muxName)) {
this.lastPaneCount.set(session.muxName, count);
}
// If enableMouseMode fails, DON'T update lastPaneCount — retry next poll
} else {
if (this.disableMouseMode(session.muxName)) {
if (await this.disableMouseMode(session.muxName)) {
this.lastPaneCount.set(session.muxName, count);
}
}
@@ -1473,7 +1481,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Allows clicking to select panes in agent team split-pane layouts.
* When mouse mode is on, tmux intercepts mouse events (slow selection, no browser copy).
*/
enableMouseMode(muxName: string): boolean {
async enableMouseMode(muxName: string): Promise<boolean> {
if (IS_TEST_MODE) return true;
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in enableMouseMode:', muxName);
@@ -1481,7 +1489,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
execSync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1497,7 +1505,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Disable mouse mode for an existing tmux session.
* Restores native xterm.js text selection and browser clipboard copy.
*/
disableMouseMode(muxName: string): boolean {
async disableMouseMode(muxName: string): Promise<boolean> {
if (IS_TEST_MODE) return true;
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in disableMouseMode:', muxName);
@@ -1505,7 +1513,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
execSync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1522,9 +1530,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Called by TeamWatcher when teammates spawn/despawn panes.
* Uses `tmux list-panes` for bulletproof detection — counts actual panes, not config.
*/
syncMouseMode(muxName: string): boolean {
async syncMouseMode(muxName: string): Promise<boolean> {
if (IS_TEST_MODE) return true;
const panes = this.listPanes(muxName);
const panes = await this.listPanes(muxName);
if (panes.length > 1) {
return this.enableMouseMode(muxName);
} else {
@@ -1536,7 +1544,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* List all panes in a tmux session.
* Returns structured info for each pane.
*/
listPanes(muxName: string): PaneInfo[] {
async listPanes(muxName: string): Promise<PaneInfo[]> {
if (IS_TEST_MODE) return [];
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in listPanes:', muxName);
@@ -1544,10 +1552,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
const output = execSync(
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
const output = (
await execAsync(
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
).stdout.trim();
return output
.split('\n')
+54
View File
@@ -0,0 +1,54 @@
/**
* @fileoverview Event-loop lag monitor.
*
* Node is single-threaded: any synchronous work (e.g. a blocking `execSync`)
* freezes the whole event loop, so the HTTP server stops answering on its port
* while the process stays alive and other ports are unaffected. Such stalls
* self-heal and never restart the process, so a periodic loopback healthcheck
* misses them entirely — they leave no trace.
*
* This monitor samples how late a fixed-interval timer actually fires versus when
* it was scheduled; the excess is time the loop was blocked. When that exceeds a
* threshold it logs the measured stall, turning otherwise-invisible "port briefly
* unreachable" incidents into a timestamped, quantified log line.
*
* @module utils/event-loop-monitor
*/
export interface EventLoopMonitorHandle {
stop(): void;
}
/**
* Start sampling event-loop lag.
*
* @param sampleMs How often to sample (and the baseline interval lag is measured against).
* @param thresholdMs Only stalls at or above this many ms are logged (noise floor).
* @param log Sink for stall reports; defaults to console.warn (lands in the web log).
*/
export function startEventLoopMonitor(
sampleMs = 1000,
thresholdMs = 1000,
log: (msg: string) => void = (m) => console.warn(m)
): EventLoopMonitorHandle {
let last = performance.now();
const timer = setInterval(() => {
const now = performance.now();
// Lag = elapsed beyond the scheduled interval = time the loop was blocked.
const lag = Math.round(now - last - sampleMs);
if (lag >= thresholdMs) {
log(`[EventLoopLag] event loop blocked ~${lag}ms (at ${new Date().toISOString()})`);
}
last = now;
}, sampleMs);
// Never keep the process alive solely for this monitor.
timer.unref?.();
return {
stop() {
clearInterval(timer);
},
};
}
+2
View File
@@ -9,6 +9,8 @@
export { BufferAccumulator } from './buffer-accumulator.js';
export { CleanupManager } from './cleanup-manager.js';
export { Debouncer, KeyedDebouncer } from './debouncer.js';
export { startEventLoopMonitor } from './event-loop-monitor.js';
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
export { StaleExpirationMap } from './stale-expiration-map.js';
export {
ANSI_ESCAPE_PATTERN_FULL,
+3 -2
View File
@@ -1110,9 +1110,10 @@ class CodemanApp {
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
_renderMarkdown(text) {
const src = text || '';
if (typeof marked !== 'undefined' && marked.parse) {
try {
const prepared = this._preprocessAsciiArt(text);
const prepared = this._preprocessAsciiArt(src);
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
// Wrap tables in a horizontal-scroll container so they overflow gracefully
// on mobile without collapsing into block-level cells.
@@ -1168,7 +1169,7 @@ class CodemanApp {
} catch { /* fall through */ }
}
// Fallback: escape HTML and preserve whitespace
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escaped = src.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
}
+53 -1
View File
@@ -113,7 +113,14 @@ Object.assign(CodemanApp.prototype, {
const paths = [];
for (const file of files) {
try {
const path = await this._uploadPasteImage(sessionId, file);
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
// MIME claim "image/jpeg", which passes the server's extension allowlist
// but fails its magic-byte check ("bytes do not match declared type").
// Decoding through the browser and re-encoding guarantees the bytes
// match the extension we send.
const normalized = await this._normalizeImageForUpload(file);
const path = await this._uploadPasteImage(sessionId, normalized);
paths.push(path);
} catch (err) {
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
@@ -145,4 +152,49 @@ Object.assign(CodemanApp.prototype, {
return data.path;
},
// Decode an image File through the browser and re-encode it to a format the
// server accepts, so the uploaded bytes always match their declared
// extension. PNG is re-encoded as PNG (preserves transparency); everything
// else (JPEG, WebP, HEIF, unknown) becomes JPEG. Animated GIFs are passed
// through untouched since a canvas would flatten them to one frame. On any
// decode/encode failure the original file is returned unchanged so the server
// still gets a chance (and logs a precise diagnostic).
async _normalizeImageForUpload(file) {
if (file.type === 'image/gif') return file;
const toPng = file.type === 'image/png';
const url = URL.createObjectURL(file);
try {
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('decode failed'));
img.src = url;
});
const width = img.naturalWidth;
const height = img.naturalHeight;
if (!width || !height) return file;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return file;
ctx.drawImage(img, 0, 0);
const mime = toPng ? 'image/png' : 'image/jpeg';
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
if (!blob) return file;
const baseName = (file.name || 'image').replace(/\.[^.]+$/, '') || 'image';
return new File([blob], baseName + (toPng ? '.png' : '.jpg'), { type: mime });
} catch (err) {
console.warn('Image re-encode failed, uploading original:', err);
return file;
} finally {
URL.revokeObjectURL(url);
}
},
});
+62 -17
View File
@@ -4,8 +4,10 @@
* Defines two exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss.
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
* keyboard on mobile: arrow up/down, /init, /clear, paste, and dismiss.
* The paste button opens a dialog that handles both text paste and image attach
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
* Destructive actions (/clear) require double-tap confirmation (2s amber state).
* Commands are sent as text + Enter separately for Ink compatibility.
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
*
@@ -49,7 +51,6 @@ const KeyboardAccessoryBar = {
</button>
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
@@ -98,7 +99,6 @@ const KeyboardAccessoryBar = {
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
@@ -128,7 +128,7 @@ const KeyboardAccessoryBar = {
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
if (refocusActions.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
(action === 'clear' && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus();
}
@@ -191,13 +191,11 @@ const KeyboardAccessoryBar = {
case 'init':
this.sendCommand('/init');
break;
case 'clear':
case 'compact': {
case 'clear': {
// Require double-tap: first tap turns amber, second tap within 2s sends
const cmd = action === 'clear' ? '/clear' : '/compact';
if (this._confirmAction === action && this._confirmTimer) {
this.clearConfirm();
this.sendCommand(cmd);
this.sendCommand('/clear');
} else {
this.setConfirm(action, btn);
}
@@ -264,8 +262,17 @@ const KeyboardAccessoryBar = {
}).catch(() => {});
},
/** Read clipboard and send contents as input */
/** Show a paste overlay with a textarea for iOS compatibility */
/** Show a paste overlay for iOS compatibility.
* Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged).
* - Image (picker): the "Image" button opens a native file picker
* (accept=image/* → camera / photo library / files), the most reliable
* way to attach a photo on mobile.
* - Image (paste): if the browser exposes image blobs on the textarea's
* paste event, we intercept them and upload directly. Support is spotty
* on mobile, so it is a best-effort enhancement layered on the picker.
* All image paths reuse app._uploadAndInsertImages() (image-input.js), which
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
pasteFromClipboard() {
if (typeof app === 'undefined' || !app.activeSessionId) return;
@@ -274,23 +281,61 @@ const KeyboardAccessoryBar = {
overlay.className = 'paste-overlay';
overlay.innerHTML = `
<div class="paste-dialog">
<textarea class="paste-textarea" placeholder="Long-press here and tap Paste"></textarea>
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach an image"></textarea>
<div class="paste-actions">
<button class="paste-image">🖼 Image</button>
<button class="paste-cancel">Cancel</button>
<button class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
</div>
`;
const textarea = overlay.querySelector('.paste-textarea');
const send = () => {
const fileInput = overlay.querySelector('.paste-file-input');
const close = () => overlay.remove();
const sendText = () => {
const text = textarea.value;
overlay.remove();
close();
if (text) app.sendInput(text);
};
overlay.querySelector('.paste-cancel').addEventListener('click', () => overlay.remove());
overlay.querySelector('.paste-send').addEventListener('click', send);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
// Filter to images, close the dialog, and hand off to the shared
// upload+insert pipeline. Returns true if any image was handled.
const handleImages = (files) => {
const images = Array.from(files || []).filter((f) => f.type.startsWith('image/'));
if (images.length === 0) return false;
close();
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images);
return true;
};
// Image picker (camera / photo library) — the reliable mobile path.
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => handleImages(fileInput.files));
// Best-effort: capture images pasted straight into the textarea.
textarea.addEventListener('paste', (e) => {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
const imageFiles = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
const blob = items[i].getAsFile();
if (blob) imageFiles.push(blob);
}
}
if (imageFiles.length > 0) {
e.preventDefault();
handleImages(imageFiles);
}
});
overlay.querySelector('.paste-cancel').addEventListener('click', close);
overlay.querySelector('.paste-send').addEventListener('click', sendText);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.body.appendChild(overlay);
textarea.focus();
+9 -1
View File
@@ -1043,7 +1043,7 @@ html.mobile-init .file-browser-panel {
margin-top: 10px;
}
.paste-cancel, .paste-new, .paste-send {
.paste-cancel, .paste-new, .paste-send, .paste-image {
padding: 8px 18px;
border: none;
border-radius: 8px;
@@ -1051,6 +1051,14 @@ html.mobile-init .file-browser-panel {
cursor: pointer;
}
/* Image attach button — left-aligned, accent outline */
.paste-image {
margin-right: auto;
background: var(--bg-tertiary, #333);
color: var(--accent-color, #7aa2f7);
border: 1px solid var(--accent-color, #7aa2f7);
}
.paste-cancel {
background: var(--bg-tertiary, #333);
color: var(--text-secondary, #aaa);
+122 -59
View File
@@ -7971,14 +7971,15 @@ kbd {
bottom: 0;
left: 0;
right: 0;
max-height: 85vh;
background: #1a1a2e;
border-top: 1px solid #333;
border-radius: 12px 12px 0 0;
max-height: 88vh;
background: #14141f;
border-top: 1px solid #2a2a3a;
border-radius: 14px 14px 0 0;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.45);
z-index: 5000;
flex-direction: column;
transform: translateY(100%);
transition: transform 0.25s ease-out;
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.response-viewer.visible {
@@ -7990,12 +7991,13 @@ kbd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #333;
padding: 14px 20px;
border-bottom: 1px solid #2a2a3a;
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
color: #e8e8ec;
letter-spacing: 0.2px;
}
.response-viewer-actions {
@@ -8078,42 +8080,80 @@ kbd {
background: rgba(109, 219, 127, 0.12);
}
/* Markdown rendered content inside response viewer */
.rv-text {
word-break: break-word;
line-height: 1.6;
/* Markdown rendered content inside response viewer.
Prose uses a proportional font for readability; code keeps monospace. */
.rv-text,
.response-viewer-body > :not(.rv-message) {
word-break: normal;
overflow-wrap: anywhere;
line-height: 1.7;
}
.rv-text p {
margin: 0 0 0.6em;
.rv-text p,
.response-viewer-body > p {
margin: 0 0 0.85em;
}
.rv-text p:last-child {
.rv-text p:last-child,
.response-viewer-body > p:last-child {
margin-bottom: 0;
}
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4 {
color: #e0e0e0;
margin: 1em 0 0.4em;
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4,
.response-viewer-body > h1, .response-viewer-body > h2,
.response-viewer-body > h3, .response-viewer-body > h4 {
color: #f2f2f6;
margin: 1.4em 0 0.5em;
line-height: 1.3;
font-weight: 700;
letter-spacing: -0.01em;
}
.rv-text h1 { font-size: 1.3em; }
.rv-text h2 { font-size: 1.15em; }
.rv-text h3 { font-size: 1.05em; }
.rv-text h1:first-child, .rv-text h2:first-child,
.response-viewer-body > h1:first-child, .response-viewer-body > h2:first-child {
margin-top: 0;
}
.rv-text code {
background: #2a2a3e;
padding: 1px 5px;
border-radius: 3px;
.rv-text h1, .response-viewer-body > h1 {
font-size: 1.55em;
padding-bottom: 0.3em;
border-bottom: 1px solid #2d2d40;
}
.rv-text h2, .response-viewer-body > h2 {
font-size: 1.3em;
color: #ffd27a;
}
.rv-text h3, .response-viewer-body > h3 {
font-size: 1.13em;
color: #bfc8ff;
}
.rv-text h4, .response-viewer-body > h4 {
font-size: 1em;
color: #c9c9d5;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rv-text code,
.response-viewer-body > :not(pre) code {
background: #262638;
color: #ffb4a2;
padding: 1px 6px;
border-radius: 4px;
font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace;
font-size: 0.9em;
}
.rv-text pre {
background: #1e1e2e;
border: 1px solid #333;
border-radius: 6px;
padding: 10px 12px;
/* Descendant (not child) combinator: code blocks are wrapped in .rv-code-wrap,
so the latest-response view (markdown rendered straight into the body) nests
<pre> one level deeper than a direct child. The historical .rv-text path
already matched via descendant; keep both in lockstep. */
.rv-text pre,
.response-viewer-body pre {
background: #0f0f1a;
border: 1px solid #2a2a3d;
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
margin: 1em 0;
-webkit-overflow-scrolling: touch;
@@ -8125,7 +8165,7 @@ kbd {
Preserve indentation (pre-wrap) but allow breaks inside long tokens
(URLs, paths, identifiers) so they don't overflow. */
.rv-text pre code,
.response-viewer-body > pre code {
.response-viewer-body pre code {
background: none;
color: #e6e6f0;
padding: 0;
@@ -8294,38 +8334,43 @@ kbd {
.rv-text ul, .rv-text ol,
.response-viewer-body > ul, .response-viewer-body > ol {
margin: 0.6em 0;
padding-left: 1.5em;
}
.rv-text pre code {
background: none;
padding: 0;
font-size: 0.85em;
line-height: 1.5;
.rv-text li,
.response-viewer-body > ul > li, .response-viewer-body > ol > li {
margin-bottom: 0.3em;
}
.rv-text ul, .rv-text ol {
margin: 0.4em 0;
padding-left: 1.4em;
.rv-text li > p { margin: 0.2em 0; }
.rv-text blockquote,
.response-viewer-body > blockquote {
border-left: 3px solid #5c7cfa;
background: rgba(92, 124, 250, 0.06);
margin: 0.8em 0;
padding: 0.5em 14px;
color: #b8b8c8;
border-radius: 0 6px 6px 0;
}
.rv-text li {
margin-bottom: 0.2em;
.rv-text strong,
.response-viewer-body > p strong,
.response-viewer-body > li strong {
color: #ffffff;
font-weight: 700;
}
.rv-text blockquote {
border-left: 3px solid #444;
margin: 0.6em 0;
padding: 0.3em 0 0.3em 12px;
color: #999;
.rv-text em,
.response-viewer-body em {
color: #e0e0ec;
}
.rv-text strong {
color: #f0f0f0;
}
.rv-text a {
color: #5c7cfa;
.rv-text a,
.response-viewer-body a {
color: #7aa2ff;
text-decoration: none;
border-bottom: 1px solid rgba(122, 162, 255, 0.35);
}
.rv-text a:hover,
@@ -8403,19 +8448,37 @@ kbd {
.rv-text hr,
.response-viewer-body > hr {
border: none;
border-top: 1px solid #333;
margin: 1em 0;
border-top: 1px solid #2d2d40;
margin: 1.5em 0;
}
.response-viewer-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 16px;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
font-size: 13px;
line-height: 1.5;
color: #d4d4d4;
overscroll-behavior: contain;
padding: 20px 22px 28px;
/* Proportional font for prose — monospace only for code/pre */
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC',
'Hiragino Sans GB', 'Segoe UI', 'Helvetica Neue', Helvetica, Arial,
'Noto Sans CJK SC', sans-serif;
font-size: 15px;
line-height: 1.7;
color: #d8d8e0;
/* Comfortable reading width on wider viewports */
--rv-content-max: 720px;
}
/* Constrain content width for readability; code blocks can still scroll horizontally */
.response-viewer-body > * {
max-width: var(--rv-content-max);
margin-left: auto;
margin-right: auto;
}
.response-viewer-body > pre,
.response-viewer-body > table,
.response-viewer-body > .rv-message {
max-width: var(--rv-content-max);
}
.response-viewer-body:empty::after {
+8
View File
@@ -1674,6 +1674,14 @@ export function registerSessionRoutes(
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
if (!imageMagicMatchesExt(imageBytes, ext)) {
// Diagnostic: on some Android galleries (e.g. MIUI) a WebP/HEIF is
// mislabeled as image/jpeg, so the declared ext passes the allowlist but
// the magic bytes do not. Log the real header so format mismatches can be
// pinned down without a reproduce-and-guess loop. The client now
// re-encodes images to JPEG/PNG before upload, so this should be rare.
console.warn(
`[paste-image] magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${imageBytes.subarray(0, 12).toString('hex')}`
);
reply.code(415);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
}
+12 -1
View File
@@ -94,7 +94,8 @@ import {
type ImageDetectedEvent,
DEFAULT_NICE_CONFIG,
} from '../types.js';
import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
import { CleanupManager, KeyedDebouncer, StaleExpirationMap, startEventLoopMonitor } from '../utils/index.js';
import type { EventLoopMonitorHandle } from '../utils/index.js';
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
import { SseEvent } from './sse-events.js';
import type { ScheduledRun } from './ports/index.js';
@@ -231,6 +232,7 @@ export class WebServer extends EventEmitter {
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private _pasteImageGcStop: (() => void) | null = null;
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
private teamWatcherHandlers: {
teamCreated: (config: unknown) => void;
teamUpdated: (config: unknown) => void;
@@ -1551,6 +1553,10 @@ export class WebServer extends EventEmitter {
// older than 7 days from each live session's .claude-images/ hourly.
if (!this.testMode) {
this._pasteImageGcStop = startPasteImageGc({ sessions: this.sessions });
// Surface event-loop stalls (e.g. a slow synchronous tmux/ps call) so the
// intermittent ":3000 briefly unreachable, process never restarts" class of
// incident leaves a quantified log line instead of vanishing silently.
this._eventLoopMonitor = startEventLoopMonitor();
}
await this.app.listen({ port: this.port, host: '0.0.0.0' });
@@ -1911,6 +1917,11 @@ export class WebServer extends EventEmitter {
this._pasteImageGcStop = null;
}
if (this._eventLoopMonitor) {
this._eventLoopMonitor.stop();
this._eventLoopMonitor = null;
}
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();