From f1058daa2cc6725c67c17d8dd4e68f7dfcd4e041 Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 28 Jan 2026 02:31:58 +0100 Subject: [PATCH] feat(image-popup): add auto-popup for detected screenshots and images Implements image file watching in session working directories with automatic popup display in the web UI: - Add ImageWatcher class using chokidar for cross-platform file watching - Add ImageDetectedEvent type for SSE broadcasting - Integrate with session lifecycle (watch on create, unwatch on delete) - Add image:detected SSE event handler in frontend - Create draggable image popup window with open-in-new-tab support - Add CSS styling with compact wizard layout Supports: png, jpg, jpeg, gif, webp, bmp, svg Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 4 +- package-lock.json | 190 ++++++++++++++++++++++++++- package.json | 1 + src/image-watcher.ts | 267 ++++++++++++++++++++++++++++++++++++++ src/plan-orchestrator.ts | 105 ++++++++++++++- src/ralph-tracker.ts | 15 +++ src/types.ts | 19 +++ src/web/public/app.js | 134 +++++++++++++++++++ src/web/public/index.html | 4 +- src/web/public/styles.css | 134 +++++++++++++++++-- src/web/server.ts | 31 +++++ 11 files changed, 884 insertions(+), 20 deletions(-) create mode 100644 src/image-watcher.ts diff --git a/CLAUDE.md b/CLAUDE.md index fb8d9a36..9b8f8533 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Claudeman is a Claude Code session manager with web interface and autonomous Ral **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js -**TypeScript Strictness**: `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false` +**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false` **Requirements**: Node.js 18+, Claude CLI, GNU Screen @@ -111,7 +111,7 @@ journalctl --user -u claudeman-web -f **E2E tests**: Use Playwright. Run `npx playwright install chromium` first. See `test/e2e/fixtures/` for helpers. E2E config provides ports, timeouts, and helpers. -**Test config**: Vitest runs with `globals: true` (no imports needed for `describe`/`it`/`expect`) and `fileParallelism: false` (files run sequentially to respect screen limits). Test timeout is 30s, teardown timeout is 60s. +**Test config**: Vitest runs with `globals: true` (no imports needed for `describe`/`it`/`expect`) and `fileParallelism: false` (files run sequentially to respect screen limits). Unit test timeout is 30s, teardown timeout is 60s. E2E tests have longer timeouts defined in `test/e2e/e2e.config.ts` (90s test, 30s session creation). **Test safety**: `test/setup.ts` provides: - Screen concurrency limiter (max 10) diff --git a/package-lock.json b/package-lock.json index e509ec43..5fd22386 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "claudeman", - "version": "0.1396", + "version": "0.1406", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claudeman", - "version": "0.1396", + "version": "0.1406", "hasInstallScript": true, "license": "MIT", "dependencies": { "@fastify/static": "^8.0.0", "@modelcontextprotocol/sdk": "^1.25.3", "chalk": "^5.3.0", + "chokidar": "^3.6.0", "commander": "^12.1.0", "fastify": "^5.1.0", "ink": "^6.6.0", @@ -1657,6 +1658,31 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1852,6 +1878,18 @@ "node": ">=10.0.0" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1876,6 +1914,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -1956,6 +2006,30 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/chromium-bidi": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-13.0.1.tgz", @@ -2910,6 +2984,18 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2983,7 +3069,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3129,6 +3214,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3403,6 +3500,27 @@ "dev": true, "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -3418,6 +3536,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-in-ci": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", @@ -3433,6 +3563,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3837,6 +3976,15 @@ "node-addon-api": "^7.1.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4409,6 +4557,30 @@ "react": "^19.2.0" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -5169,6 +5341,18 @@ "node": ">=14.0.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toad-cache": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", diff --git a/package.json b/package.json index 767a22f3..347278df 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@fastify/static": "^8.0.0", "@modelcontextprotocol/sdk": "^1.25.3", "chalk": "^5.3.0", + "chokidar": "^3.6.0", "commander": "^12.1.0", "fastify": "^5.1.0", "ink": "^6.6.0", diff --git a/src/image-watcher.ts b/src/image-watcher.ts new file mode 100644 index 00000000..18fc3056 --- /dev/null +++ b/src/image-watcher.ts @@ -0,0 +1,267 @@ +/** + * @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 'events'; +import { watch, type FSWatcher } from 'chokidar'; +import { basename, extname } from 'path'; +import { statSync } from 'fs'; +import type { ImageDetectedEvent } from './types.js'; + +// ========== Types ========== + +export interface ImageWatcherEvents { + 'image:detected': (event: ImageDetectedEvent) => void; + 'image:error': (error: Error, sessionId?: string) => void; +} + +// ========== Constants ========== + +/** Supported image file extensions (lowercase) */ +const IMAGE_EXTENSIONS = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.bmp', + '.svg', +]); + +/** 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; + +// ========== 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(); + + /** Map of sessionId -> working directory path */ + private sessionDirs = new Map(); + + /** Debounce timers for rapid image creation */ + private debounceTimers = new Map(); + + /** Whether the watcher is currently running */ + private _isRunning = false; + + constructor() { + super(); + } + + // ========== 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 + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); + } + + /** + * Start watching a session's working directory for new images. + * + * @param sessionId - Claudeman 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, + }, + // Only watch files at the top level (not subdirectories) + depth: 0, + // Ignore non-image files early for performance + ignored: (path: string) => { + const ext = extname(path).toLowerCase(); + // Don't ignore directories (needed for watching to work) + // Ignore files that aren't images + return ext !== '' && !IMAGE_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 - Claudeman 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 timer for this session + const timer = this.debounceTimers.get(sessionId); + if (timer) { + clearTimeout(timer); + this.debounceTimers.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 an image and emits the detection event. + */ + private handleNewFile(sessionId: string, filePath: string): void { + const ext = extname(filePath).toLowerCase(); + + // Double-check it's an image extension + if (!IMAGE_EXTENSIONS.has(ext)) { + return; + } + + // Debounce rapid file creation (e.g., multiple screenshots quickly) + const existingTimer = this.debounceTimers.get(filePath); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + this.debounceTimers.delete(filePath); + this.emitImageDetected(sessionId, filePath); + }, DEBOUNCE_DELAY_MS); + + this.debounceTimers.set(filePath, timer); + } + + /** + * 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 event: ImageDetectedEvent = { + sessionId, + filePath, + 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); + } + } +} + +// Export singleton instance for convenience +export const imageWatcher = new ImageWatcher(); diff --git a/src/plan-orchestrator.ts b/src/plan-orchestrator.ts index d6cd871c..17b2700b 100644 --- a/src/plan-orchestrator.ts +++ b/src/plan-orchestrator.ts @@ -986,6 +986,9 @@ ${prompt} // Update the case CLAUDE.md with research context links this.updateCaseClaudeMd(); + + // Update the project's main CLAUDE.md with Ralph Loop context + this.updateProjectClaudeMd(); } catch (err) { console.error('[PlanOrchestrator] Failed to save final result:', err); } @@ -1068,6 +1071,71 @@ Use \`/init\` to load this context, then read specific files as needed. } } + /** + * Update the project's main CLAUDE.md with Ralph Loop context. + * This ensures Claude instances running in the project directory know about the current task, + * where to find the plan files, and how to track todos. + */ + private updateProjectClaudeMd(): void { + if (!this.outputDir || !this.workingDir) return; + + try { + const projectClaudeMdPath = join(this.workingDir, 'CLAUDE.md'); + const caseDir = dirname(this.outputDir); + + // Build the Ralph Loop context section + const contextSection = ` +## Active Ralph Loop Task + +**Current Task**: ${this.taskDescription} + +**Case Folder**: \`${caseDir}\` + +### Key Files +- **Plan Summary**: \`${caseDir}/ralph-wizard/summary.md\` - Human-readable plan overview +- **Todo Items**: \`${caseDir}/ralph-wizard/final-result.json\` - Contains \`items\` array with all todo tasks +- **Research**: \`${caseDir}/ralph-wizard/research/result.json\` - External resources and codebase patterns + +### How to Work on This Task +1. Read the plan summary to understand the overall approach +2. Check \`final-result.json\` for the todo items array - each item has \`id\`, \`title\`, \`description\`, \`priority\` +3. Work through items in priority order (critical → high → medium → low) +4. Use \`COMPLETION_PHRASE\` when the entire task is complete + +### Research Insights +Check \`${caseDir}/ralph-wizard/research/result.json\` for: +- External GitHub repos and documentation links to reference +- Existing codebase patterns to follow +- Technical recommendations from the research phase +`; + + // Read existing CLAUDE.md or create new one + let existingContent = ''; + if (existsSync(projectClaudeMdPath)) { + existingContent = readFileSync(projectClaudeMdPath, 'utf-8'); + + // Remove any existing Ralph Loop section to avoid duplicates + const sectionStart = existingContent.indexOf('## Active Ralph Loop Task'); + if (sectionStart !== -1) { + // Find the next ## heading or end of file + const nextSectionMatch = existingContent.slice(sectionStart + 1).match(/\n## /); + const sectionEnd = nextSectionMatch + ? sectionStart + 1 + nextSectionMatch.index! + : existingContent.length; + existingContent = existingContent.slice(0, sectionStart) + existingContent.slice(sectionEnd); + } + } + + // Append the new section + const newContent = existingContent.trimEnd() + '\n' + contextSection; + writeFileSync(projectClaudeMdPath, newContent, 'utf-8'); + + console.log(`[PlanOrchestrator] Updated project CLAUDE.md with Ralph Loop context at ${projectClaudeMdPath}`); + } catch (err) { + console.error('[PlanOrchestrator] Failed to update project CLAUDE.md:', err); + } + } + /** * Generate a human-readable summary of the plan. */ @@ -1307,15 +1375,42 @@ Use \`/init\` to load this context, then read specific files as needed. try { const prompt = RESEARCH_AGENT_PROMPT.replace('{TASK}', taskDescription); - onProgress?.('research', 'Gathering external resources and codebase context...'); + onProgress?.('research', 'Starting research agent (web search + codebase exploration)...'); + + // Periodic progress updates showing elapsed time with contextual messages + const researchPhases = [ + { min: 0, msg: 'Searching for relevant GitHub repos and documentation...' }, + { min: 30, msg: 'Exploring official docs and best practice guides...' }, + { min: 60, msg: 'Analyzing codebase patterns and conventions...' }, + { min: 120, msg: 'Synthesizing findings and recommendations...' }, + { min: 180, msg: 'Compiling research results (complex tasks take time)...' }, + { min: 300, msg: 'Almost done - finalizing enriched task description...' }, + ]; - // Periodic progress updates showing elapsed time const progressInterval = setInterval(() => { const elapsedSec = Math.floor((Date.now() - startTime) / 1000); const timeoutSec = Math.floor(RESEARCH_TIMEOUT_MS / 1000); - onProgress?.('research', `Research agent working... (${elapsedSec}s / ${timeoutSec}s timeout)`); - console.log(`[PlanOrchestrator] Research agent: ${elapsedSec}s elapsed (timeout: ${timeoutSec}s)`); - }, 30000); // Update every 30 seconds + + // Find appropriate phase message based on elapsed time + let phaseMsg = researchPhases[0].msg; + for (const phase of researchPhases) { + if (elapsedSec >= phase.min) { + phaseMsg = phase.msg; + } + } + + const progressMsg = `${phaseMsg} (${elapsedSec}s / ${timeoutSec}s)`; + onProgress?.('research', progressMsg); + onSubagent?.({ + type: 'progress', + agentId, + agentType: 'research', + model: MODEL_RESEARCH, + status: 'running', + detail: progressMsg, + }); + console.log(`[PlanOrchestrator] Research agent: ${elapsedSec}s - ${phaseMsg}`); + }, 15000); // Update every 15 seconds let result: string; try { diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index ac5922f9..bb28332e 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -510,6 +510,14 @@ export class RalphTracker extends EventEmitter { /** Path to the @fix_plan.md file being watched */ private _fixPlanPath: string | null = null; + /** + * When @fix_plan.md is active, treat it as the source of truth for todo status. + * This prevents output-based detection from overriding file-based status. + */ + private get isFileAuthoritative(): boolean { + return this._fixPlanPath !== null; + } + // ========== Enhanced Plan Management ========== /** Current version of the plan (incremented on changes) */ @@ -1077,6 +1085,10 @@ export class RalphTracker extends EventEmitter { * @fires loopUpdate - If loop state changes */ private detectAllTasksComplete(line: string): void { + // When @fix_plan.md is active, only trust the file for todo status + // This prevents false positives from Claude saying "all done" in conversation + if (this.isFileAuthoritative) return; + // Only trigger if line is a clear standalone completion message // Avoid matching commentary like "once all tasks are complete..." if (!ALL_COMPLETE_PATTERN.test(line)) return; @@ -1130,6 +1142,9 @@ export class RalphTracker extends EventEmitter { * Only marks a todo complete if we can match it by task number. */ private detectTaskCompletion(line: string): void { + // When @fix_plan.md is active, only trust the file for todo status + if (this.isFileAuthoritative) return; + if (!TASK_DONE_PATTERN.test(line)) return; // Only act on explicit task number references like "Task 8 is done" diff --git a/src/types.ts b/src/types.ts index ebe161ae..8bc297d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1163,6 +1163,25 @@ export interface ActiveBashTool { sessionId: string; } +// ========== Image Watcher Types ========== + +/** + * Event emitted when a new image file is detected in a session's working directory. + * Used to trigger automatic image popup display in the web UI. + */ +export interface ImageDetectedEvent { + /** Claudeman session ID where the image was detected */ + sessionId: string; + /** Full path to the detected image file */ + filePath: string; + /** Image file name (basename) */ + fileName: string; + /** Timestamp when the image was detected */ + timestamp: number; + /** File size in bytes */ + size: number; +} + // ========== Spawn1337 Protocol Re-exports ========== export type { diff --git a/src/web/public/app.js b/src/web/public/app.js index 64798750..dd3b2706 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -476,6 +476,10 @@ class ClaudemanApp { this.projectInsightsPanelVisible = false; this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization + // Image popup windows (auto-open for detected screenshots/images) + this.imagePopups = new Map(); // Map + this.imagePopupZIndex = 3000; + // Tab alert states: Map this.tabAlerts = new Map(); @@ -1129,6 +1133,7 @@ class ClaudemanApp { this.ralphStates.delete(data.id); // Clean up ralph state for this session this.projectInsights.delete(data.id); // Clean up project insights for this session this.closeSessionLogViewerWindows(data.id); // Close log viewer windows for this session + this.closeSessionImagePopups(data.id); // Close image popup windows for this session this.closeSessionSubagentWindows(data.id, true); // Close subagent windows and cleanup activity data // Clean up idle timer for this session const idleTimer = this.idleTimers.get(data.id); @@ -1912,6 +1917,14 @@ class ClaudemanApp { }, 5 * 60 * 1000); // 5 minutes }); + // ========== Image Detection Events (Screenshots & Generated Images) ========== + + addListener('image:detected', (e) => { + const data = JSON.parse(e.data); + console.log('[Image Detected]', data); + this.openImagePopup(data); + }); + // Plan subagent visibility events (show Opus agents during plan generation) addListener('plan:subagent', (e) => { const data = JSON.parse(e.data); @@ -9670,6 +9683,127 @@ class ClaudemanApp { } } + // ========== Image Popup Windows (Auto-popup for Screenshots) ========== + + /** + * Open a popup window to display a detected image. + * Called automatically when image:detected SSE event is received. + */ + openImagePopup(imageEvent) { + const { sessionId, filePath, fileName, timestamp, size } = imageEvent; + + // Create unique window ID + const imageId = `${sessionId}-${timestamp}`; + + // If window already exists for this image, focus it + if (this.imagePopups.has(imageId)) { + const existing = this.imagePopups.get(imageId); + existing.element.style.zIndex = ++this.imagePopupZIndex; + return; + } + + // Calculate position (cascade from center, with offset for multiple popups) + const windowCount = this.imagePopups.size; + const centerX = (window.innerWidth - 600) / 2; + const centerY = (window.innerHeight - 500) / 2; + const offsetX = centerX + (windowCount % 5) * 30; + const offsetY = centerY + (windowCount % 5) * 30; + + // Get session name for display + const session = this.sessions.get(sessionId); + const sessionName = session?.name || sessionId.substring(0, 8); + + // Format file size + const sizeKB = (size / 1024).toFixed(1); + + // Build image URL using the existing file-raw endpoint + const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(fileName)}`; + + // Create window element + const win = document.createElement('div'); + win.className = 'image-popup-window'; + win.id = `image-popup-${imageId}`; + win.style.left = `${offsetX}px`; + win.style.top = `${offsetY}px`; + win.style.zIndex = ++this.imagePopupZIndex; + + win.innerHTML = ` +
+
+ 🖼️ + ${this.escapeHtml(fileName)} + ${this.escapeHtml(sessionName)} + ${sizeKB} KB +
+
+ + +
+
+
+ ${this.escapeHtml(fileName)} +
+ `; + + document.body.appendChild(win); + + // Make draggable + const dragListeners = this.makeWindowDraggable(win, win.querySelector('.image-popup-header')); + + // Focus on click + win.addEventListener('mousedown', () => { + win.style.zIndex = ++this.imagePopupZIndex; + }); + + // Store reference + this.imagePopups.set(imageId, { + element: win, + sessionId, + filePath, + dragListeners, + }); + } + + /** + * Close an image popup window. + */ + closeImagePopup(imageId) { + const popupData = this.imagePopups.get(imageId); + if (!popupData) return; + + // Clean up global drag event listeners + if (popupData.dragListeners) { + document.removeEventListener('mousemove', popupData.dragListeners.move); + document.removeEventListener('mouseup', popupData.dragListeners.up); + } + + // Remove element + popupData.element.remove(); + + // Remove from map + this.imagePopups.delete(imageId); + } + + /** + * Open image in a new browser tab. + */ + openImageInNewTab(url) { + window.open(url, '_blank'); + } + + /** + * Close all image popups for a session. + */ + closeSessionImagePopups(sessionId) { + for (const [imageId, data] of this.imagePopups) { + if (data.sessionId === sessionId) { + this.closeImagePopup(imageId); + } + } + } + // ========== Screen Sessions (in Monitor Panel) ========== async loadScreens() { diff --git a/src/web/public/index.html b/src/web/public/index.html index e6f45e16..e6b28cb7 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -942,7 +942,7 @@