mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-26 02:49:33 +01:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3c496e1a4 | ||
|
|
eb831487a0 | ||
|
|
68594ac395 | ||
|
|
ec38fd11bf | ||
|
|
06f9ff6d9c |
No files matched your search
@@ -66,3 +66,6 @@ commands
|
|||||||
todo.md
|
todo.md
|
||||||
@fix_plan.md
|
@fix_plan.md
|
||||||
readme-preview.mjs
|
readme-preview.mjs
|
||||||
|
|
||||||
|
# Uploaded images land here under each session working dir (runtime artifact)
|
||||||
|
.claude-images/
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
# aicodeman
|
# 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
|
## 0.7.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -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.
|
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
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.7.1",
|
"version": "0.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.7.1",
|
"version": "0.8.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"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",
|
"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",
|
||||||
|
|||||||
+44
-34
@@ -1153,19 +1153,23 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const psOutput = execSync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
|
const psOutput = (
|
||||||
encoding: 'utf-8',
|
await execAsync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
encoding: 'utf-8',
|
||||||
}).trim();
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
).stdout.trim();
|
||||||
|
|
||||||
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
|
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
|
||||||
|
|
||||||
let childCount = 0;
|
let childCount = 0;
|
||||||
try {
|
try {
|
||||||
const childOutput = execSync(`pgrep -P ${session.pid} | wc -l`, {
|
const childOutput = (
|
||||||
encoding: 'utf-8',
|
await execAsync(`pgrep -P ${session.pid} | wc -l`, {
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
encoding: 'utf-8',
|
||||||
}).trim();
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
).stdout.trim();
|
||||||
childCount = parseInt(childOutput, 10) || 0;
|
childCount = parseInt(childOutput, 10) || 0;
|
||||||
} catch {
|
} catch {
|
||||||
// No children or command failed
|
// No children or command failed
|
||||||
@@ -1202,13 +1206,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// Step 1: Get descendant PIDs
|
// Step 1: Get descendant PIDs
|
||||||
const descendantMap = new Map<number, number[]>();
|
const descendantMap = new Map<number, number[]>();
|
||||||
|
|
||||||
const pgrepOutput = execSync(
|
const pgrepOutput = (
|
||||||
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
|
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,
|
encoding: 'utf-8',
|
||||||
}
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
).trim();
|
}
|
||||||
|
)
|
||||||
|
).stdout.trim();
|
||||||
|
|
||||||
for (const line of pgrepOutput.split('\n')) {
|
for (const line of pgrepOutput.split('\n')) {
|
||||||
const [pidStr, childrenStr] = line.split(':');
|
const [pidStr, childrenStr] = line.split(':');
|
||||||
@@ -1233,10 +1239,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// Step 3: Single ps call
|
// Step 3: Single ps call
|
||||||
const pidArray = Array.from(allPids);
|
const pidArray = Array.from(allPids);
|
||||||
if (pidArray.length > 0) {
|
if (pidArray.length > 0) {
|
||||||
const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
|
const psOutput = (
|
||||||
encoding: 'utf-8',
|
await execAsync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
encoding: 'utf-8',
|
||||||
}).trim();
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
).stdout.trim();
|
||||||
|
|
||||||
const processStats = new Map<number, { rss: number; cpu: number }>();
|
const processStats = new Map<number, { rss: number; cpu: number }>();
|
||||||
for (const line of psOutput.split('\n')) {
|
for (const line of psOutput.split('\n')) {
|
||||||
@@ -1324,11 +1332,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
clearInterval(this.mouseSyncInterval);
|
clearInterval(this.mouseSyncInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.mouseSyncInterval = setInterval(() => {
|
this.mouseSyncInterval = setInterval(async () => {
|
||||||
if (IS_TEST_MODE) return;
|
if (IS_TEST_MODE) return;
|
||||||
|
|
||||||
for (const session of this.sessions.values()) {
|
for (const session of this.sessions.values()) {
|
||||||
const panes = this.listPanes(session.muxName);
|
const panes = await this.listPanes(session.muxName);
|
||||||
const count = panes.length;
|
const count = panes.length;
|
||||||
if (count === 0) continue;
|
if (count === 0) continue;
|
||||||
|
|
||||||
@@ -1337,12 +1345,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
|
|
||||||
// Pane count changed — toggle mouse mode
|
// Pane count changed — toggle mouse mode
|
||||||
if (count > 1) {
|
if (count > 1) {
|
||||||
if (this.enableMouseMode(session.muxName)) {
|
if (await this.enableMouseMode(session.muxName)) {
|
||||||
this.lastPaneCount.set(session.muxName, count);
|
this.lastPaneCount.set(session.muxName, count);
|
||||||
}
|
}
|
||||||
// If enableMouseMode fails, DON'T update lastPaneCount — retry next poll
|
// If enableMouseMode fails, DON'T update lastPaneCount — retry next poll
|
||||||
} else {
|
} else {
|
||||||
if (this.disableMouseMode(session.muxName)) {
|
if (await this.disableMouseMode(session.muxName)) {
|
||||||
this.lastPaneCount.set(session.muxName, count);
|
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.
|
* 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).
|
* 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 (IS_TEST_MODE) return true;
|
||||||
if (!isValidMuxName(muxName)) {
|
if (!isValidMuxName(muxName)) {
|
||||||
console.error('[TmuxManager] Invalid session name in enableMouseMode:', muxName);
|
console.error('[TmuxManager] Invalid session name in enableMouseMode:', muxName);
|
||||||
@@ -1481,7 +1489,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
|
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
@@ -1497,7 +1505,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
* Disable mouse mode for an existing tmux session.
|
* Disable mouse mode for an existing tmux session.
|
||||||
* Restores native xterm.js text selection and browser clipboard copy.
|
* 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 (IS_TEST_MODE) return true;
|
||||||
if (!isValidMuxName(muxName)) {
|
if (!isValidMuxName(muxName)) {
|
||||||
console.error('[TmuxManager] Invalid session name in disableMouseMode:', muxName);
|
console.error('[TmuxManager] Invalid session name in disableMouseMode:', muxName);
|
||||||
@@ -1505,7 +1513,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
|
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
@@ -1522,9 +1530,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
* Called by TeamWatcher when teammates spawn/despawn panes.
|
* Called by TeamWatcher when teammates spawn/despawn panes.
|
||||||
* Uses `tmux list-panes` for bulletproof detection — counts actual panes, not config.
|
* 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;
|
if (IS_TEST_MODE) return true;
|
||||||
const panes = this.listPanes(muxName);
|
const panes = await this.listPanes(muxName);
|
||||||
if (panes.length > 1) {
|
if (panes.length > 1) {
|
||||||
return this.enableMouseMode(muxName);
|
return this.enableMouseMode(muxName);
|
||||||
} else {
|
} else {
|
||||||
@@ -1536,7 +1544,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
* List all panes in a tmux session.
|
* List all panes in a tmux session.
|
||||||
* Returns structured info for each pane.
|
* Returns structured info for each pane.
|
||||||
*/
|
*/
|
||||||
listPanes(muxName: string): PaneInfo[] {
|
async listPanes(muxName: string): Promise<PaneInfo[]> {
|
||||||
if (IS_TEST_MODE) return [];
|
if (IS_TEST_MODE) return [];
|
||||||
if (!isValidMuxName(muxName)) {
|
if (!isValidMuxName(muxName)) {
|
||||||
console.error('[TmuxManager] Invalid session name in listPanes:', muxName);
|
console.error('[TmuxManager] Invalid session name in listPanes:', muxName);
|
||||||
@@ -1544,10 +1552,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = execSync(
|
const output = (
|
||||||
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
|
await execAsync(
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
|
||||||
).trim();
|
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||||
|
)
|
||||||
|
).stdout.trim();
|
||||||
|
|
||||||
return output
|
return output
|
||||||
.split('\n')
|
.split('\n')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
export { BufferAccumulator } from './buffer-accumulator.js';
|
export { BufferAccumulator } from './buffer-accumulator.js';
|
||||||
export { CleanupManager } from './cleanup-manager.js';
|
export { CleanupManager } from './cleanup-manager.js';
|
||||||
export { Debouncer, KeyedDebouncer } from './debouncer.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 { StaleExpirationMap } from './stale-expiration-map.js';
|
||||||
export {
|
export {
|
||||||
ANSI_ESCAPE_PATTERN_FULL,
|
ANSI_ESCAPE_PATTERN_FULL,
|
||||||
|
|||||||
@@ -1110,9 +1110,10 @@ class CodemanApp {
|
|||||||
|
|
||||||
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
||||||
_renderMarkdown(text) {
|
_renderMarkdown(text) {
|
||||||
|
const src = text || '';
|
||||||
if (typeof marked !== 'undefined' && marked.parse) {
|
if (typeof marked !== 'undefined' && marked.parse) {
|
||||||
try {
|
try {
|
||||||
const prepared = this._preprocessAsciiArt(text);
|
const prepared = this._preprocessAsciiArt(src);
|
||||||
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
|
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
|
||||||
// Wrap tables in a horizontal-scroll container so they overflow gracefully
|
// Wrap tables in a horizontal-scroll container so they overflow gracefully
|
||||||
// on mobile without collapsing into block-level cells.
|
// on mobile without collapsing into block-level cells.
|
||||||
@@ -1168,7 +1169,7 @@ class CodemanApp {
|
|||||||
} catch { /* fall through */ }
|
} catch { /* fall through */ }
|
||||||
}
|
}
|
||||||
// Fallback: escape HTML and preserve whitespace
|
// Fallback: escape HTML and preserve whitespace
|
||||||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
const escaped = src.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
|
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const paths = [];
|
const paths = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
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);
|
paths.push(path);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
|
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
|
||||||
@@ -145,4 +152,49 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return data.path;
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -4,8 +4,10 @@
|
|||||||
* Defines two exports:
|
* Defines two exports:
|
||||||
*
|
*
|
||||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss.
|
* keyboard on mobile: arrow up/down, /init, /clear, paste, and dismiss.
|
||||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
* 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.
|
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||||
*
|
*
|
||||||
@@ -49,7 +51,6 @@ const KeyboardAccessoryBar = {
|
|||||||
</button>
|
</button>
|
||||||
<button class="accessory-btn" data-action="init" title="/init">/init</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="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">
|
<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">
|
<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"/>
|
<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="esc" title="Escape">Esc</button>
|
||||||
<button class="accessory-btn" data-action="init" title="/init">/init</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="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">
|
<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">
|
<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"/>
|
<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)
|
// 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']);
|
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) ||
|
if (refocusActions.has(action) ||
|
||||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
(action === 'clear' && this._confirmAction)) {
|
||||||
if (typeof app !== 'undefined' && app.terminal) {
|
if (typeof app !== 'undefined' && app.terminal) {
|
||||||
app.terminal.focus();
|
app.terminal.focus();
|
||||||
}
|
}
|
||||||
@@ -191,13 +191,11 @@ const KeyboardAccessoryBar = {
|
|||||||
case 'init':
|
case 'init':
|
||||||
this.sendCommand('/init');
|
this.sendCommand('/init');
|
||||||
break;
|
break;
|
||||||
case 'clear':
|
case 'clear': {
|
||||||
case 'compact': {
|
|
||||||
// Require double-tap: first tap turns amber, second tap within 2s sends
|
// Require double-tap: first tap turns amber, second tap within 2s sends
|
||||||
const cmd = action === 'clear' ? '/clear' : '/compact';
|
|
||||||
if (this._confirmAction === action && this._confirmTimer) {
|
if (this._confirmAction === action && this._confirmTimer) {
|
||||||
this.clearConfirm();
|
this.clearConfirm();
|
||||||
this.sendCommand(cmd);
|
this.sendCommand('/clear');
|
||||||
} else {
|
} else {
|
||||||
this.setConfirm(action, btn);
|
this.setConfirm(action, btn);
|
||||||
}
|
}
|
||||||
@@ -264,8 +262,17 @@ const KeyboardAccessoryBar = {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Read clipboard and send contents as input */
|
/** Show a paste overlay for iOS compatibility.
|
||||||
/** Show a paste overlay with a textarea 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() {
|
pasteFromClipboard() {
|
||||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||||
|
|
||||||
@@ -274,23 +281,61 @@ const KeyboardAccessoryBar = {
|
|||||||
overlay.className = 'paste-overlay';
|
overlay.className = 'paste-overlay';
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div class="paste-dialog">
|
<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">
|
<div class="paste-actions">
|
||||||
|
<button class="paste-image">🖼 Image</button>
|
||||||
<button class="paste-cancel">Cancel</button>
|
<button class="paste-cancel">Cancel</button>
|
||||||
<button class="paste-send">Send</button>
|
<button class="paste-send">Send</button>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const textarea = overlay.querySelector('.paste-textarea');
|
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;
|
const text = textarea.value;
|
||||||
overlay.remove();
|
close();
|
||||||
if (text) app.sendInput(text);
|
if (text) app.sendInput(text);
|
||||||
};
|
};
|
||||||
overlay.querySelector('.paste-cancel').addEventListener('click', () => overlay.remove());
|
|
||||||
overlay.querySelector('.paste-send').addEventListener('click', send);
|
// Filter to images, close the dialog, and hand off to the shared
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
// 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);
|
document.body.appendChild(overlay);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|||||||
@@ -1043,7 +1043,7 @@ html.mobile-init .file-browser-panel {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paste-cancel, .paste-new, .paste-send {
|
.paste-cancel, .paste-new, .paste-send, .paste-image {
|
||||||
padding: 8px 18px;
|
padding: 8px 18px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -1051,6 +1051,14 @@ html.mobile-init .file-browser-panel {
|
|||||||
cursor: pointer;
|
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 {
|
.paste-cancel {
|
||||||
background: var(--bg-tertiary, #333);
|
background: var(--bg-tertiary, #333);
|
||||||
color: var(--text-secondary, #aaa);
|
color: var(--text-secondary, #aaa);
|
||||||
|
|||||||
+122
-59
@@ -7971,14 +7971,15 @@ kbd {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
max-height: 85vh;
|
max-height: 88vh;
|
||||||
background: #1a1a2e;
|
background: #14141f;
|
||||||
border-top: 1px solid #333;
|
border-top: 1px solid #2a2a3a;
|
||||||
border-radius: 12px 12px 0 0;
|
border-radius: 14px 14px 0 0;
|
||||||
|
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.45);
|
||||||
z-index: 5000;
|
z-index: 5000;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transform: translateY(100%);
|
transform: translateY(100%);
|
||||||
transition: transform 0.25s ease-out;
|
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.response-viewer.visible {
|
.response-viewer.visible {
|
||||||
@@ -7990,12 +7991,13 @@ kbd {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: 14px 20px;
|
||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #2a2a3a;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e0e0e0;
|
color: #e8e8ec;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.response-viewer-actions {
|
.response-viewer-actions {
|
||||||
@@ -8078,42 +8080,80 @@ kbd {
|
|||||||
background: rgba(109, 219, 127, 0.12);
|
background: rgba(109, 219, 127, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Markdown rendered content inside response viewer */
|
/* Markdown rendered content inside response viewer.
|
||||||
.rv-text {
|
Prose uses a proportional font for readability; code keeps monospace. */
|
||||||
word-break: break-word;
|
.rv-text,
|
||||||
line-height: 1.6;
|
.response-viewer-body > :not(.rv-message) {
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rv-text p {
|
.rv-text p,
|
||||||
margin: 0 0 0.6em;
|
.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;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4 {
|
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4,
|
||||||
color: #e0e0e0;
|
.response-viewer-body > h1, .response-viewer-body > h2,
|
||||||
margin: 1em 0 0.4em;
|
.response-viewer-body > h3, .response-viewer-body > h4 {
|
||||||
|
color: #f2f2f6;
|
||||||
|
margin: 1.4em 0 0.5em;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rv-text h1 { font-size: 1.3em; }
|
.rv-text h1:first-child, .rv-text h2:first-child,
|
||||||
.rv-text h2 { font-size: 1.15em; }
|
.response-viewer-body > h1:first-child, .response-viewer-body > h2:first-child {
|
||||||
.rv-text h3 { font-size: 1.05em; }
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.rv-text code {
|
.rv-text h1, .response-viewer-body > h1 {
|
||||||
background: #2a2a3e;
|
font-size: 1.55em;
|
||||||
padding: 1px 5px;
|
padding-bottom: 0.3em;
|
||||||
border-radius: 3px;
|
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;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rv-text pre {
|
/* Descendant (not child) combinator: code blocks are wrapped in .rv-code-wrap,
|
||||||
background: #1e1e2e;
|
so the latest-response view (markdown rendered straight into the body) nests
|
||||||
border: 1px solid #333;
|
<pre> one level deeper than a direct child. The historical .rv-text path
|
||||||
border-radius: 6px;
|
already matched via descendant; keep both in lockstep. */
|
||||||
padding: 10px 12px;
|
.rv-text pre,
|
||||||
|
.response-viewer-body pre {
|
||||||
|
background: #0f0f1a;
|
||||||
|
border: 1px solid #2a2a3d;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -8125,7 +8165,7 @@ kbd {
|
|||||||
Preserve indentation (pre-wrap) but allow breaks inside long tokens
|
Preserve indentation (pre-wrap) but allow breaks inside long tokens
|
||||||
(URLs, paths, identifiers) so they don't overflow. */
|
(URLs, paths, identifiers) so they don't overflow. */
|
||||||
.rv-text pre code,
|
.rv-text pre code,
|
||||||
.response-viewer-body > pre code {
|
.response-viewer-body pre code {
|
||||||
background: none;
|
background: none;
|
||||||
color: #e6e6f0;
|
color: #e6e6f0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -8294,38 +8334,43 @@ kbd {
|
|||||||
.rv-text ul, .rv-text ol,
|
.rv-text ul, .rv-text ol,
|
||||||
.response-viewer-body > ul, .response-viewer-body > ol {
|
.response-viewer-body > ul, .response-viewer-body > ol {
|
||||||
margin: 0.6em 0;
|
margin: 0.6em 0;
|
||||||
|
padding-left: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rv-text pre code {
|
.rv-text li,
|
||||||
background: none;
|
.response-viewer-body > ul > li, .response-viewer-body > ol > li {
|
||||||
padding: 0;
|
margin-bottom: 0.3em;
|
||||||
font-size: 0.85em;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rv-text ul, .rv-text ol {
|
.rv-text li > p { margin: 0.2em 0; }
|
||||||
margin: 0.4em 0;
|
|
||||||
padding-left: 1.4em;
|
.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 {
|
.rv-text strong,
|
||||||
margin-bottom: 0.2em;
|
.response-viewer-body > p strong,
|
||||||
|
.response-viewer-body > li strong {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rv-text blockquote {
|
.rv-text em,
|
||||||
border-left: 3px solid #444;
|
.response-viewer-body em {
|
||||||
margin: 0.6em 0;
|
color: #e0e0ec;
|
||||||
padding: 0.3em 0 0.3em 12px;
|
|
||||||
color: #999;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rv-text strong {
|
.rv-text a,
|
||||||
color: #f0f0f0;
|
.response-viewer-body a {
|
||||||
}
|
color: #7aa2ff;
|
||||||
|
|
||||||
.rv-text a {
|
|
||||||
color: #5c7cfa;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(122, 162, 255, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rv-text a:hover,
|
.rv-text a:hover,
|
||||||
@@ -8403,19 +8448,37 @@ kbd {
|
|||||||
.rv-text hr,
|
.rv-text hr,
|
||||||
.response-viewer-body > hr {
|
.response-viewer-body > hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #333;
|
border-top: 1px solid #2d2d40;
|
||||||
margin: 1em 0;
|
margin: 1.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.response-viewer-body {
|
.response-viewer-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: 16px;
|
overscroll-behavior: contain;
|
||||||
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
|
padding: 20px 22px 28px;
|
||||||
font-size: 13px;
|
/* Proportional font for prose — monospace only for code/pre */
|
||||||
line-height: 1.5;
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC',
|
||||||
color: #d4d4d4;
|
'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 {
|
.response-viewer-body:empty::after {
|
||||||
|
|||||||
@@ -1674,6 +1674,14 @@ export function registerSessionRoutes(
|
|||||||
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
|
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
|
||||||
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
|
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
|
||||||
if (!imageMagicMatchesExt(imageBytes, ext)) {
|
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);
|
reply.code(415);
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -94,7 +94,8 @@ import {
|
|||||||
type ImageDetectedEvent,
|
type ImageDetectedEvent,
|
||||||
DEFAULT_NICE_CONFIG,
|
DEFAULT_NICE_CONFIG,
|
||||||
} from '../types.js';
|
} 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 { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||||
import { SseEvent } from './sse-events.js';
|
import { SseEvent } from './sse-events.js';
|
||||||
import type { ScheduledRun } from './ports/index.js';
|
import type { ScheduledRun } from './ports/index.js';
|
||||||
@@ -231,6 +232,7 @@ export class WebServer extends EventEmitter {
|
|||||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||||
private _pasteImageGcStop: (() => void) | null = null;
|
private _pasteImageGcStop: (() => void) | null = null;
|
||||||
|
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
|
||||||
private teamWatcherHandlers: {
|
private teamWatcherHandlers: {
|
||||||
teamCreated: (config: unknown) => void;
|
teamCreated: (config: unknown) => void;
|
||||||
teamUpdated: (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.
|
// older than 7 days from each live session's .claude-images/ hourly.
|
||||||
if (!this.testMode) {
|
if (!this.testMode) {
|
||||||
this._pasteImageGcStop = startPasteImageGc({ sessions: this.sessions });
|
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' });
|
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
||||||
@@ -1911,6 +1917,11 @@ export class WebServer extends EventEmitter {
|
|||||||
this._pasteImageGcStop = null;
|
this._pasteImageGcStop = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._eventLoopMonitor) {
|
||||||
|
this._eventLoopMonitor.stop();
|
||||||
|
this._eventLoopMonitor = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Dispose all managed timers (intervals + resettable timeouts)
|
// Dispose all managed timers (intervals + resettable timeouts)
|
||||||
this.cleanup.dispose();
|
this.cleanup.dispose();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user