Files
Codeman/src/image-watcher.ts
T
Aamer Akhter 49c92e4723 COD-38 document attachment previews + thumbnails (attachment cards)
Builds on the COD-37 registry: surfaces detected/registered attachments as
dismissible cards with a first-page thumbnail and an inline preview — the
consumer the registry PR deliberately deferred.

Backend:
- document-thumbnailer: first-page PNG thumbnails (PNG passthrough; PDF via
  pdftoppm; Office via the preview cache).
- document-preview-cache: disk-cached DOCX/PPTX -> PDF conversion (LibreOffice
  / PowerShell COM), in-flight dedup, multi-converter fallback.
- file-routes: serveConvertedPreview / serveThumbnail + four routes —
  GET .../attachments/:id/preview, .../thumbnail and the workspace-path
  file-preview / file-thumbnail. Reuses the registry's TOCTOU-safe
  resolveServableAttachmentPath, so previews stream the freshly-resolved path.
- server: enrich detected attachment events with a thumbnail route.
- image-watcher: .png now routes to attachment:detected — this PR adds the card
  consumer, so the screenshot popup is no longer its only handler.

Frontend:
- panels-ui: attachment cards (addAttachmentCard, lazy stack, Clear-all,
  per-session cleanup) plus a 3-arg openFilePreview that renders registered
  attachments inline (image/PDF) or via the server-converted PDF (docx/pptx).
- app.js: wire attachment:detected -> _onAttachmentDetected and card state.
- styles: attachment-card + stack styling.

Verified: tsc / eslint / prettier / frontend-syntax clean; new thumbnailer +
preview-cache unit tests pass; full test:ci green (2861 passed); card render +
preview overlay + dismiss verified in-browser.
2026-06-12 09:09:14 -04:00

344 lines
11 KiB
TypeScript

/**
* @fileoverview Image Watcher - Detects new image files in session working directories
*
* Watches session working directories for new image files (screenshots, generated images)
* and emits events to trigger automatic popup display in the web UI.
*
* Uses chokidar for reliable cross-platform file watching with awaitWriteFinish
* to ensure files are fully written before emitting detection events.
*/
import { EventEmitter } from 'node:events';
import { watch, type FSWatcher } from 'chokidar';
import { basename, extname, relative } from 'node:path';
import { statSync } from 'node:fs';
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
import { KeyedDebouncer } from './utils/index.js';
// ========== Types ==========
// ========== Constants ==========
/** Supported image file extensions (lowercase) */
const IMAGE_POPUP_EXTENSIONS = new Set(['.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
const ATTACHMENT_EXTENSIONS = new Set(['.png', '.pdf', '.docx', '.pptx']);
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
/** Time to wait for file writes to stabilize (ms) */
const STABILITY_THRESHOLD_MS = 500;
/** Poll interval for checking file write stability (ms) */
const POLL_INTERVAL_MS = 100;
/** Debounce delay for rapid image creation (ms) */
const DEBOUNCE_DELAY_MS = 200;
/** Max images emitted per session within the burst window before throttling */
const BURST_LIMIT = 20;
/** Time window for burst detection (ms) — resets after this period of quiet */
const BURST_WINDOW_MS = 10_000;
// ========== ImageWatcher Class ==========
/**
* Watches session working directories for new image files.
*
* Follows the SubagentWatcher pattern: extends EventEmitter, manages
* file watchers in Maps, emits typed events.
*
* @example
* ```typescript
* const watcher = new ImageWatcher();
* watcher.on('image:detected', (event) => {
* console.log(`New image in session ${event.sessionId}: ${event.fileName}`);
* });
* watcher.watchSession('session-123', '/path/to/working/dir');
* ```
*/
export class ImageWatcher extends EventEmitter {
/** Map of sessionId -> FSWatcher for per-session directory watching */
private sessionWatchers = new Map<string, FSWatcher>();
/** Map of sessionId -> working directory path */
private sessionDirs = new Map<string, string>();
/** Per-file debouncer for rapid image creation */
private fileDeb = new KeyedDebouncer(DEBOUNCE_DELAY_MS);
/** Track which session owns each debounced file (for cleanup) */
private fileToSession = new Map<string, string>();
/** Per-session burst tracking: sessionId -> { count, windowStart } */
private burstTrackers = new Map<string, { count: number; windowStart: number }>();
/** Whether the watcher is currently running */
private _isRunning = false;
constructor() {
super();
this.setMaxListeners(50);
}
// ========== Public API ==========
/**
* Check if the watcher is currently running
*/
isRunning(): boolean {
return this._isRunning;
}
/**
* Start the image watcher.
* After calling start(), use watchSession() to add directories to monitor.
*/
start(): void {
if (this._isRunning) return;
this._isRunning = true;
}
/**
* Stop the image watcher and clean up all resources.
*/
stop(): void {
this._isRunning = false;
// Close all session watchers
for (const [sessionId, watcher] of this.sessionWatchers) {
try {
watcher.close();
} catch (error) {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
}
}
this.sessionWatchers.clear();
this.sessionDirs.clear();
// Clear all debounce timers
this.fileDeb.dispose();
this.fileToSession.clear();
this.burstTrackers.clear();
}
/**
* Start watching a session's working directory for new images.
*
* @param sessionId - Codeman session ID
* @param workingDir - Path to the session's working directory
*/
watchSession(sessionId: string, workingDir: string): void {
if (!this._isRunning) {
this.start();
}
// Don't double-watch the same session
if (this.sessionWatchers.has(sessionId)) {
// If working directory changed, unwatch old and watch new
if (this.sessionDirs.get(sessionId) !== workingDir) {
this.unwatchSession(sessionId);
} else {
return;
}
}
this.sessionDirs.set(sessionId, workingDir);
try {
// Create chokidar watcher for the directory
const watcher = watch(workingDir, {
// Only detect NEW files, not existing ones
ignoreInitial: true,
// Wait for file writes to stabilize before emitting
awaitWriteFinish: {
stabilityThreshold: STABILITY_THRESHOLD_MS,
pollInterval: POLL_INTERVAL_MS,
},
// Watch all subdirectories (images may be saved in src/, assets/, etc.)
// Ignore common heavy directories for performance
ignored: (path: string) => {
// Skip node_modules, .git, and other heavy directories
if (
path.includes('/node_modules/') ||
path.includes('/.git/') ||
path.includes('/dist/') ||
path.includes('/.next/')
) {
return true;
}
const ext = extname(path).toLowerCase();
// Don't ignore directories (needed for watching to work)
// Ignore files that aren't previewable images/documents
return ext !== '' && !DETECTED_FILE_EXTENSIONS.has(ext);
},
});
// Handle new file detection
watcher.on('add', (filePath: string) => {
this.handleNewFile(sessionId, filePath);
});
// Handle watcher errors
watcher.on('error', (error: Error) => {
this.emit('image:error', error, sessionId);
});
this.sessionWatchers.set(sessionId, watcher);
} catch (error) {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
}
}
/**
* Stop watching a session's working directory.
*
* @param sessionId - Codeman session ID to stop watching
*/
unwatchSession(sessionId: string): void {
const watcher = this.sessionWatchers.get(sessionId);
if (watcher) {
try {
watcher.close();
} catch (error) {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
}
this.sessionWatchers.delete(sessionId);
}
this.sessionDirs.delete(sessionId);
// Clear any pending debounce timers for this session
const toCancel: string[] = [];
for (const [filePath, ownerId] of this.fileToSession) {
if (ownerId === sessionId) {
toCancel.push(filePath);
}
}
for (const filePath of toCancel) {
this.fileDeb.cancelKey(filePath);
this.fileToSession.delete(filePath);
}
this.burstTrackers.delete(sessionId);
}
/**
* Get list of currently watched session IDs.
*/
getWatchedSessions(): string[] {
return Array.from(this.sessionWatchers.keys());
}
// ========== Private Methods ==========
/**
* Handle a new file being detected.
* Verifies it's a previewable image/document and emits the detection event.
*/
private handleNewFile(sessionId: string, filePath: string): void {
const ext = extname(filePath).toLowerCase();
// Double-check it's a supported extension
if (!DETECTED_FILE_EXTENSIONS.has(ext)) {
return;
}
const isAttachment = ATTACHMENT_EXTENSIONS.has(ext);
// Burst limit: skip if too many images detected for this session in a short window
const now = Date.now();
let burst = this.burstTrackers.get(sessionId);
if (burst) {
if (now - burst.windowStart > BURST_WINDOW_MS) {
// Window expired, reset
burst = { count: 0, windowStart: now };
this.burstTrackers.set(sessionId, burst);
}
if (burst.count >= BURST_LIMIT) {
return; // Throttled — too many images in this window
}
} else {
burst = { count: 0, windowStart: now };
this.burstTrackers.set(sessionId, burst);
}
// Debounce rapid file creation (e.g., multiple screenshots quickly)
this.fileDeb.schedule(filePath, () => {
this.fileToSession.delete(filePath);
if (isAttachment) {
this.emitAttachmentDetected(sessionId, filePath);
} else {
this.emitImageDetected(sessionId, filePath);
}
// Increment burst count on actual emission (not on detection)
const b = this.burstTrackers.get(sessionId);
if (b) b.count++;
});
this.fileToSession.set(filePath, sessionId);
}
/**
* Emit the image:detected event with file metadata.
*/
private emitImageDetected(sessionId: string, filePath: string): void {
try {
const stat = statSync(filePath);
const fileName = basename(filePath);
const workingDir = this.sessionDirs.get(sessionId);
// Compute relative path from working directory (for file-raw endpoint)
const relativePath = workingDir ? relative(workingDir, filePath) : fileName;
const event: ImageDetectedEvent = {
sessionId,
filePath,
relativePath,
fileName,
timestamp: Date.now(),
size: stat.size,
};
this.emit('image:detected', event);
} catch (error) {
// File may have been deleted between detection and stat
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
}
}
/**
* Emit the attachment:detected event with file metadata.
*/
private emitAttachmentDetected(sessionId: string, filePath: string): void {
try {
const stat = statSync(filePath);
const fileName = basename(filePath);
const workingDir = this.sessionDirs.get(sessionId);
const relativePath = workingDir ? relative(workingDir, filePath) : fileName;
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
const event: AttachmentDetectedEvent = {
sessionId,
filePath,
relativePath,
fileName,
extension,
attachmentType: this.getAttachmentType(extension),
timestamp: Date.now(),
size: stat.size,
};
this.emit('attachment:detected', event);
} catch (error) {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
}
}
private getAttachmentType(extension: string): AttachmentDetectedType {
if (extension === 'png') return 'image';
if (extension === 'pdf') return 'pdf';
if (extension === 'docx') return 'document';
if (extension === 'pptx') return 'presentation';
return 'document';
}
}
// Export singleton instance for convenience
export const imageWatcher = new ImageWatcher();