diff --git a/README.md b/README.md index cfda7912..bafc15ac 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,13 @@ A Claude Code session manager with an autonomous Ralph Loop for task assignment ## Features +- **Web Interface**: Beautiful, responsive web UI for managing sessions and running prompts - **Session Management**: Spawn and manage multiple Claude CLI sessions as subprocesses +- **Timed Runs**: Schedule Claude to work for a specific duration with live countdown +- **Real-time Output**: Stream Claude's responses in real-time via Server-Sent Events - **Task Queue**: Priority-based task queue with dependency support - **Ralph Loop**: Autonomous control loop that assigns tasks to idle sessions and monitors completion -- **Time-Aware Loops**: Set minimum durations for extended autonomous work sessions +- **Cost Tracking**: Track total API costs across all sessions - **State Persistence**: All state persisted to `~/.claudeman/state.json` ## Installation @@ -20,6 +23,24 @@ npm link # Optional: make 'claudeman' available globally ## Quick Start +### Web Interface (Recommended) + +```bash +# Start the web interface +claudeman web + +# Open http://localhost:3000 in your browser +``` + +The web interface provides: +- **Prompt Input**: Enter prompts and optionally set a working directory +- **Duration Timer**: Set duration in minutes for timed runs (0 = single run) +- **Live Output**: See Claude's response in real-time as it streams +- **Countdown**: Large timer display when running timed jobs +- **Session Monitoring**: View all active sessions at the bottom panel + +### CLI Usage + ```bash # Start a Claude session claudeman start --dir /path/to/project @@ -37,6 +58,16 @@ claudeman status ## Commands +### Web Interface + +```bash +# Start web interface on default port (3000) +claudeman web + +# Use a different port +claudeman web --port 8080 +``` + ### Session Management ```bash diff --git a/package-lock.json b/package-lock.json index 4b877fbe..2dc57ffe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@fastify/static": "^8.0.0", "chalk": "^5.3.0", "commander": "^12.1.0", + "fastify": "^5.1.0", "uuid": "^10.0.0" }, "bin": { @@ -468,6 +470,233 @@ "node": ">=18" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz", + "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^0.5.4", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^11.0.0" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", @@ -485,6 +714,88 @@ "dev": true, "license": "MIT" }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz", + "integrity": "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -497,6 +808,24 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -506,6 +835,75 @@ "node": ">=18" } }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -548,6 +946,161 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.2.0.tgz", + "integrity": "sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.1.tgz", + "integrity": "sha512-ZW7S4fxlZhE+tYWVokFzjh+i56R+buYKNGhrVl6DtN8sxkyMEzpJnzvO8A/ZZrsg5w6X37u6I4EOQikYS5DXpA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", + "integrity": "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -576,6 +1129,318 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pino": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.2.0.tgz", + "integrity": "sha512-NFnZqUliT+OHkRXVSf8vdOr13N1wv31hRryVjqbreVh/SDCNaI6mnRDDq89HVRCbem1SAl7yj04OANeqP0nT6A==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -586,6 +1451,305 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -639,6 +1803,112 @@ "bin": { "uuid": "dist/bin/uuid" } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index 60ab670d..0b8c7c61 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,10 @@ "claudeman": "./dist/index.js" }, "scripts": { - "build": "tsc", + "build": "tsc && mkdir -p dist/web && cp -r src/web/public dist/web/", "start": "node dist/index.js", "dev": "tsx src/index.ts", + "web": "node dist/index.js web", "clean": "rm -rf dist" }, "keywords": [ @@ -22,8 +23,10 @@ "author": "", "license": "MIT", "dependencies": { + "@fastify/static": "^8.0.0", "chalk": "^5.3.0", "commander": "^12.1.0", + "fastify": "^5.1.0", "uuid": "^10.0.0" }, "devDependencies": { diff --git a/src/cli.ts b/src/cli.ts index 155ca31c..1f65e06e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -449,4 +449,25 @@ program console.log(''); }); +// Web interface command +program + .command('web') + .description('Start the web interface') + .option('-p, --port ', 'Port to listen on', '3000') + .action(async (options) => { + const { startWebServer } = await import('./web/server.js'); + const port = parseInt(options.port, 10); + + console.log(chalk.cyan(`Starting Claudeman web interface on port ${port}...`)); + + try { + await startWebServer(port); + console.log(chalk.green(`\n✓ Web interface running at http://localhost:${port}`)); + console.log(chalk.gray(' Press Ctrl+C to stop\n')); + } catch (err) { + console.error(chalk.red(`✗ Failed to start web server: ${(err as Error).message}`)); + process.exit(1); + } + }); + export { program }; diff --git a/src/session.ts b/src/session.ts index f0d2882d..b7c74e83 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,11 +3,29 @@ import { EventEmitter } from 'node:events'; import { v4 as uuidv4 } from 'uuid'; import { SessionState, SessionStatus, SessionConfig } from './types.js'; +export interface ClaudeMessage { + type: 'system' | 'assistant' | 'user' | 'result'; + subtype?: string; + session_id?: string; + message?: { + content: Array<{ type: string; text?: string }>; + usage?: { + input_tokens: number; + output_tokens: number; + }; + }; + result?: string; + is_error?: boolean; + total_cost_usd?: number; + duration_ms?: number; +} + export interface SessionEvents { output: (data: string) => void; + message: (msg: ClaudeMessage) => void; error: (data: string) => void; exit: (code: number | null) => void; - completion: (phrase: string) => void; + completion: (result: string, cost: number) => void; } export class Session extends EventEmitter { @@ -19,8 +37,13 @@ export class Session extends EventEmitter { private _status: SessionStatus = 'idle'; private _currentTaskId: string | null = null; private _outputBuffer: string = ''; + private _textOutput: string = ''; private _errorBuffer: string = ''; private _lastActivityAt: number; + private _claudeSessionId: string | null = null; + private _totalCost: number = 0; + private _messages: ClaudeMessage[] = []; + private _lineBuffer: string = ''; constructor(config: Partial & { workingDir: string }) { super(); @@ -46,6 +69,10 @@ export class Session extends EventEmitter { return this._outputBuffer; } + get textOutput(): string { + return this._textOutput; + } + get errorBuffer(): string { return this._errorBuffer; } @@ -54,6 +81,18 @@ export class Session extends EventEmitter { return this._lastActivityAt; } + get claudeSessionId(): string | null { + return this._claudeSessionId; + } + + get totalCost(): number { + return this._totalCost; + } + + get messages(): ClaudeMessage[] { + return this._messages; + } + isIdle(): boolean { return this._status === 'idle'; } @@ -78,30 +117,49 @@ export class Session extends EventEmitter { }; } - start(): Promise { + toDetailedState() { + return { + ...this.toState(), + claudeSessionId: this._claudeSessionId, + totalCost: this._totalCost, + textOutput: this._textOutput, + messageCount: this._messages.length, + }; + } + + async runPrompt(prompt: string): Promise<{ result: string; cost: number }> { return new Promise((resolve, reject) => { if (this.process) { - reject(new Error('Session already started')); + reject(new Error('Session already has a running process')); return; } + this._status = 'busy'; + this._outputBuffer = ''; + this._textOutput = ''; + this._errorBuffer = ''; + this._messages = []; + this._lineBuffer = ''; + this._lastActivityAt = Date.now(); + try { - // Spawn claude CLI with --print flag for non-interactive mode - this.process = spawn('claude', ['--print'], { + // Spawn claude with streaming JSON output + this.process = spawn('claude', [ + '-p', + '--output-format', 'stream-json', + prompt + ], { cwd: this.workingDir, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env }, }); - this._status = 'idle'; - this._lastActivityAt = Date.now(); - this.process.stdout?.on('data', (data: Buffer) => { const text = data.toString(); this._outputBuffer += text; this._lastActivityAt = Date.now(); this.emit('output', text); - this.checkForCompletion(text); + this.processJsonLines(text); }); this.process.stderr?.on('data', (data: Buffer) => { @@ -113,22 +171,32 @@ export class Session extends EventEmitter { this.process.on('error', (err) => { this._status = 'error'; - this.emit('error', err.message); + this.process = null; reject(err); }); this.process.on('exit', (code) => { - this._status = 'stopped'; this.process = null; + + // Find the result message + const resultMsg = this._messages.find(m => m.type === 'result'); + + if (resultMsg && !resultMsg.is_error) { + this._status = 'idle'; + const cost = resultMsg.total_cost_usd || 0; + this._totalCost += cost; + this.emit('completion', resultMsg.result || '', cost); + resolve({ result: resultMsg.result || '', cost }); + } else if (code !== 0 || (resultMsg && resultMsg.is_error)) { + this._status = 'error'; + reject(new Error(this._errorBuffer || 'Process exited with error')); + } else { + this._status = 'idle'; + resolve({ result: this._textOutput, cost: 0 }); + } + this.emit('exit', code); }); - - // Give process time to start - setTimeout(() => { - if (this.process && !this.process.killed) { - resolve(); - } - }, 100); } catch (err) { this._status = 'error'; reject(err); @@ -136,6 +204,62 @@ export class Session extends EventEmitter { }); } + private processJsonLines(chunk: string): void { + this._lineBuffer += chunk; + const lines = this._lineBuffer.split('\n'); + + // Keep incomplete line in buffer + this._lineBuffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const msg = JSON.parse(line) as ClaudeMessage; + this._messages.push(msg); + this.emit('message', msg); + + // Extract text content + if (msg.type === 'system' && msg.session_id) { + this._claudeSessionId = msg.session_id; + } + + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + this._textOutput += block.text; + } + } + } + + if (msg.type === 'result') { + if (msg.total_cost_usd) { + this._totalCost += msg.total_cost_usd; + } + } + } catch { + // Not JSON, treat as raw text + this._textOutput += line + '\n'; + } + } + } + } + + // Legacy method for compatibility with session-manager + async start(): Promise { + // Session is ready by default, actual process starts with runPrompt + this._status = 'idle'; + } + + // Legacy method for sending input - wraps runPrompt + async sendInput(input: string): Promise { + this._status = 'busy'; + this._lastActivityAt = Date.now(); + // Run the prompt asynchronously + this.runPrompt(input).catch(err => { + this.emit('error', err.message); + }); + } + stop(): Promise { return new Promise((resolve) => { if (!this.process) { @@ -152,11 +276,8 @@ export class Session extends EventEmitter { }; this.process.once('exit', cleanup); - - // Try graceful shutdown first this.process.kill('SIGTERM'); - // Force kill after timeout setTimeout(() => { if (this.process && !this.process.killed) { this.process.kill('SIGKILL'); @@ -165,30 +286,13 @@ export class Session extends EventEmitter { }); } - async sendInput(input: string): Promise { - if (!this.process || !this.process.stdin) { - throw new Error('Session not started or stdin not available'); - } - - this._status = 'busy'; - this._lastActivityAt = Date.now(); - - return new Promise((resolve, reject) => { - this.process!.stdin!.write(input + '\n', (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); - } - assignTask(taskId: string): void { this._currentTaskId = taskId; this._status = 'busy'; this._outputBuffer = ''; + this._textOutput = ''; this._errorBuffer = ''; + this._messages = []; this._lastActivityAt = Date.now(); } @@ -199,7 +303,7 @@ export class Session extends EventEmitter { } getOutput(): string { - return this._outputBuffer; + return this._textOutput; } getError(): string { @@ -208,28 +312,8 @@ export class Session extends EventEmitter { clearBuffers(): void { this._outputBuffer = ''; + this._textOutput = ''; this._errorBuffer = ''; - } - - private checkForCompletion(text: string): void { - // Check for completion phrase pattern: ... - const promiseMatch = text.match(/([^<]+)<\/promise>/); - if (promiseMatch) { - this.emit('completion', promiseMatch[1]); - } - - // Also check for common completion indicators - const completionIndicators = [ - /Task completed successfully/i, - /All tasks done/i, - /✓ Complete/i, - ]; - - for (const pattern of completionIndicators) { - if (pattern.test(text)) { - this.emit('completion', 'auto-detected'); - break; - } - } + this._messages = []; } } diff --git a/src/web/public/app.js b/src/web/public/app.js new file mode 100644 index 00000000..dc79e017 --- /dev/null +++ b/src/web/public/app.js @@ -0,0 +1,356 @@ +// Claudeman App +class ClaudemanApp { + constructor() { + this.sessions = new Map(); + this.currentRun = null; + this.totalCost = 0; + this.totalTasks = 0; + this.eventSource = null; + this.sessionsCollapsed = true; + + this.init(); + } + + init() { + this.connectSSE(); + this.loadState(); + this.startTimerUpdates(); + + // Start with sessions collapsed + document.getElementById('sessionsPanel').classList.add('collapsed'); + } + + // SSE Connection + connectSSE() { + this.eventSource = new EventSource('/api/events'); + + this.eventSource.onopen = () => { + this.setConnectionStatus('connected'); + }; + + this.eventSource.onerror = () => { + this.setConnectionStatus('disconnected'); + setTimeout(() => this.connectSSE(), 3000); + }; + + this.eventSource.addEventListener('init', (e) => { + const data = JSON.parse(e.data); + this.handleInit(data); + }); + + this.eventSource.addEventListener('session:created', (e) => { + const data = JSON.parse(e.data); + this.sessions.set(data.id, data); + this.renderSessions(); + }); + + this.eventSource.addEventListener('session:updated', (e) => { + const data = JSON.parse(e.data); + this.sessions.set(data.id, data); + this.updateStats(); + this.renderSessions(); + }); + + this.eventSource.addEventListener('session:output', (e) => { + const data = JSON.parse(e.data); + this.appendOutput(data.data); + this.updateSessionOutput(data.id, data.data); + }); + + this.eventSource.addEventListener('session:message', (e) => { + const data = JSON.parse(e.data); + const msg = data.message; + + if (msg.type === 'assistant' && msg.message?.content) { + const text = msg.message.content + .filter(b => b.type === 'text') + .map(b => b.text) + .join(''); + if (text) { + this.appendOutput(text); + } + } + }); + + this.eventSource.addEventListener('session:completion', (e) => { + const data = JSON.parse(e.data); + this.totalCost += data.cost || 0; + this.totalTasks++; + this.appendOutput(`\n\n✓ Completed (Cost: $${(data.cost || 0).toFixed(4)})\n`); + this.updateStats(); + this.setRunning(false); + }); + + this.eventSource.addEventListener('session:error', (e) => { + const data = JSON.parse(e.data); + this.appendOutput(`\n❌ Error: ${data.error}\n`, 'error'); + }); + + this.eventSource.addEventListener('scheduled:created', (e) => { + const data = JSON.parse(e.data); + this.currentRun = data; + this.showTimer(); + }); + + this.eventSource.addEventListener('scheduled:updated', (e) => { + const data = JSON.parse(e.data); + this.currentRun = data; + this.updateTimer(); + }); + + this.eventSource.addEventListener('scheduled:completed', (e) => { + const data = JSON.parse(e.data); + this.currentRun = data; + this.hideTimer(); + this.appendOutput(`\n\n🎉 Scheduled run completed! Tasks: ${data.completedTasks}, Cost: $${data.totalCost.toFixed(4)}\n`); + }); + + this.eventSource.addEventListener('scheduled:stopped', (e) => { + const data = JSON.parse(e.data); + this.currentRun = data; + this.hideTimer(); + this.appendOutput(`\n\n⏹ Scheduled run stopped.\n`); + }); + + this.eventSource.addEventListener('scheduled:log', (e) => { + const data = JSON.parse(e.data); + this.appendOutput(`\n${data.log}`); + }); + } + + setConnectionStatus(status) { + const el = document.getElementById('connectionStatus'); + const dot = el.querySelector('.status-dot'); + const text = el.querySelector('span:last-child'); + + dot.className = 'status-dot ' + status; + text.textContent = status === 'connected' ? 'Connected' : 'Disconnected'; + } + + handleInit(data) { + this.sessions.clear(); + data.sessions.forEach(s => this.sessions.set(s.id, s)); + + // Check for active scheduled run + const activeRun = data.scheduledRuns.find(r => r.status === 'running'); + if (activeRun) { + this.currentRun = activeRun; + this.showTimer(); + } + + // Calculate totals + this.totalCost = data.sessions.reduce((sum, s) => sum + (s.totalCost || 0), 0); + this.totalCost += data.scheduledRuns.reduce((sum, r) => sum + (r.totalCost || 0), 0); + this.totalTasks = data.scheduledRuns.reduce((sum, r) => sum + (r.completedTasks || 0), 0); + + this.updateStats(); + this.renderSessions(); + } + + async loadState() { + try { + const res = await fetch('/api/status'); + const data = await res.json(); + this.handleInit(data); + } catch (err) { + console.error('Failed to load state:', err); + } + } + + // Actions + async startRun() { + const prompt = document.getElementById('promptInput').value.trim(); + const dir = document.getElementById('dirInput').value.trim(); + const duration = parseInt(document.getElementById('durationInput').value) || 0; + + if (!prompt) { + alert('Please enter a prompt'); + return; + } + + this.setRunning(true); + this.clearOutput(); + this.appendOutput('Starting...\n\n'); + + try { + if (duration > 0) { + // Scheduled run with timer + const res = await fetch('/api/scheduled', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt, + workingDir: dir || undefined, + durationMinutes: duration + }) + }); + const data = await res.json(); + if (!data.success) { + throw new Error(data.error); + } + } else { + // Single run + const res = await fetch('/api/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt, + workingDir: dir || undefined + }) + }); + const data = await res.json(); + if (!data.success) { + throw new Error(data.error); + } + } + } catch (err) { + this.appendOutput(`❌ Error: ${err.message}\n`, 'error'); + this.setRunning(false); + } + } + + async stopCurrentRun() { + if (!this.currentRun) return; + + try { + await fetch(`/api/scheduled/${this.currentRun.id}`, { + method: 'DELETE' + }); + } catch (err) { + alert('Error stopping run: ' + err.message); + } + } + + setRunning(running) { + const btn = document.getElementById('runBtn'); + if (running) { + btn.disabled = true; + btn.innerHTML = ' Running...'; + } else { + btn.disabled = false; + btn.innerHTML = '▶ Run'; + } + } + + // Output + appendOutput(text, type = '') { + const container = document.getElementById('outputContainer'); + + // Remove placeholder if present + const placeholder = container.querySelector('.output-placeholder'); + if (placeholder) { + container.innerHTML = ''; + } + + const span = document.createElement('span'); + span.className = type ? `msg-${type}` : ''; + span.textContent = text; + container.appendChild(span); + container.scrollTop = container.scrollHeight; + } + + clearOutput() { + const container = document.getElementById('outputContainer'); + container.innerHTML = ` +
+
💬
+
Run a prompt to see Claude's output here
+
+ `; + } + + // Timer + showTimer() { + document.getElementById('timerBanner').style.display = 'block'; + this.updateTimer(); + } + + hideTimer() { + document.getElementById('timerBanner').style.display = 'none'; + this.currentRun = null; + } + + updateTimer() { + if (!this.currentRun || this.currentRun.status !== 'running') return; + + const now = Date.now(); + const remaining = Math.max(0, this.currentRun.endAt - now); + const total = this.currentRun.endAt - this.currentRun.startedAt; + const elapsed = now - this.currentRun.startedAt; + const percent = Math.min(100, (elapsed / total) * 100); + + document.getElementById('timerValue').textContent = this.formatTime(remaining); + document.getElementById('timerProgress').style.width = `${percent}%`; + document.getElementById('timerTasks').textContent = `${this.currentRun.completedTasks} tasks completed`; + document.getElementById('timerCost').textContent = `$${this.currentRun.totalCost.toFixed(4)}`; + } + + startTimerUpdates() { + setInterval(() => this.updateTimer(), 1000); + } + + formatTime(ms) { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + + // Stats + updateStats() { + document.getElementById('statCost').textContent = `$${this.totalCost.toFixed(2)}`; + document.getElementById('statTasks').textContent = this.totalTasks; + } + + // Sessions + toggleSessions() { + const panel = document.getElementById('sessionsPanel'); + panel.classList.toggle('collapsed'); + this.sessionsCollapsed = panel.classList.contains('collapsed'); + } + + renderSessions() { + const count = this.sessions.size; + document.getElementById('sessionCount').textContent = count; + + const list = document.getElementById('sessionsList'); + + if (count === 0) { + list.innerHTML = '
No active sessions
'; + return; + } + + list.innerHTML = Array.from(this.sessions.values()).map(s => ` +
+
+
+ + ${s.id.slice(0, 8)} +
+ $${(s.totalCost || 0).toFixed(4)} +
+
${this.escapeHtml(s.textOutput || '')}
+
+ `).join(''); + } + + updateSessionOutput(sessionId, text) { + const el = document.getElementById(`session-output-${sessionId}`); + if (el) { + el.textContent += text; + el.scrollTop = el.scrollHeight; + } + } + + escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} + +// Initialize +const app = new ClaudemanApp(); diff --git a/src/web/public/index.html b/src/web/public/index.html new file mode 100644 index 00000000..71ad6672 --- /dev/null +++ b/src/web/public/index.html @@ -0,0 +1,109 @@ + + + + + + Claudeman + + + +
+ +
+
+

⚡ Claudeman

+
+ + Connecting +
+
+
+
+ $0.00 + Total Cost +
+
+ 0 + Tasks Done +
+
+
+ + + + + +
+ +
+

Run Claude

+ +
+ + +
+ +
+
+ + +
+
+ + + 0 = single run +
+
+ +
+ +
+
+ + +
+
+

Output

+
+ +
+
+ +
+
+
💬
+
Run a prompt to see Claude's output here
+
+
+
+
+ + + +
+ + + + diff --git a/src/web/public/styles.css b/src/web/public/styles.css new file mode 100644 index 00000000..fbc1ed19 --- /dev/null +++ b/src/web/public/styles.css @@ -0,0 +1,522 @@ +/* Variables */ +:root { + --bg-dark: #0a0a0a; + --bg-card: #141414; + --bg-input: #1a1a1a; + --bg-hover: #222; + --border: #2a2a2a; + --text: #eee; + --text-dim: #888; + --text-muted: #555; + --accent: #3b82f6; + --accent-hover: #60a5fa; + --green: #22c55e; + --yellow: #eab308; + --red: #ef4444; + --radius: 8px; +} + +/* Reset */ +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg-dark); + color: var(--text); + min-height: 100vh; +} + +/* Layout */ +.app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Header */ +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background: var(--bg-card); + border-bottom: 1px solid var(--border); +} + +.header-left { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.logo { + font-size: 1.25rem; + font-weight: 600; +} + +.connection-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--text-dim); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--yellow); +} + +.status-dot.connected { background: var(--green); } +.status-dot.disconnected { background: var(--red); } + +.header-stats { + display: flex; + gap: 2rem; +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.stat-value { + font-size: 1.25rem; + font-weight: 600; + color: var(--green); +} + +.stat-label { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Timer Banner */ +.timer-banner { + background: linear-gradient(135deg, #1e3a5f 0%, #0f172a 100%); + border-bottom: 1px solid var(--accent); + padding: 1rem 1.5rem; +} + +.timer-info { + display: flex; + align-items: baseline; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.timer-label { + font-size: 0.875rem; + color: var(--text-dim); +} + +.timer-value { + font-size: 2.5rem; + font-weight: 700; + font-family: 'SF Mono', Monaco, monospace; + color: var(--accent-hover); + letter-spacing: 0.05em; +} + +.timer-progress { + height: 6px; + background: var(--bg-input); + border-radius: 3px; + overflow: hidden; + margin-bottom: 0.75rem; +} + +.timer-progress-fill { + height: 100%; + background: var(--accent); + transition: width 1s linear; +} + +.timer-meta { + display: flex; + align-items: center; + gap: 1.5rem; + font-size: 0.875rem; + color: var(--text-dim); +} + +/* Main */ +.main { + display: grid; + grid-template-columns: 350px 1fr; + flex: 1; + overflow: hidden; +} + +/* Control Panel */ +.control-panel { + padding: 1.5rem; + background: var(--bg-card); + border-right: 1px solid var(--border); + overflow-y: auto; +} + +.control-panel h2 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 1.25rem; + color: var(--text-dim); +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + font-size: 0.75rem; + font-weight: 500; + color: var(--text-dim); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 0.75rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 0.875rem; + font-family: inherit; +} + +.form-group textarea { + min-height: 120px; + resize: vertical; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--accent); +} + +.form-hint { + display: block; + font-size: 0.7rem; + color: var(--text-muted); + margin-top: 0.25rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.form-actions { + margin-top: 1.5rem; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border: none; + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover { background: var(--accent-hover); } + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-danger { + background: var(--red); + color: white; +} + +.btn-large { + width: 100%; + padding: 0.875rem 1.5rem; + font-size: 1rem; +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + background: var(--bg-input); + color: var(--text-dim); + border: 1px solid var(--border); +} + +.btn-sm:hover { + background: var(--bg-hover); + color: var(--text); +} + +.btn-icon { + font-size: 0.875rem; +} + +/* Output Panel */ +.output-panel { + display: flex; + flex-direction: column; + padding: 1.5rem; + overflow: hidden; +} + +.output-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.output-header h2 { + font-size: 1rem; + font-weight: 600; + color: var(--text-dim); +} + +.output-container { + flex: 1; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + overflow-y: auto; + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 0.8125rem; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; +} + +.output-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + text-align: center; +} + +.placeholder-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.3; +} + +.placeholder-text { + font-size: 0.875rem; +} + +/* Sessions Panel */ +.sessions-panel { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--bg-card); + border-top: 1px solid var(--border); + max-height: 50vh; + overflow: hidden; + transition: max-height 0.3s ease; +} + +.sessions-panel.collapsed { + max-height: 44px; +} + +.sessions-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1.5rem; + cursor: pointer; + user-select: none; +} + +.sessions-header:hover { + background: var(--bg-hover); +} + +.sessions-header h3 { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-dim); +} + +.session-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + background: var(--accent); + border-radius: 10px; + font-size: 0.7rem; + color: white; + margin-left: 0.5rem; +} + +.toggle-icon { + color: var(--text-muted); + transition: transform 0.3s; +} + +.sessions-panel.collapsed .toggle-icon { + transform: rotate(180deg); +} + +.sessions-list { + display: flex; + gap: 1rem; + padding: 0 1.5rem 1rem; + overflow-x: auto; +} + +.session-card { + flex-shrink: 0; + width: 300px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.session-card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: var(--bg-dark); + border-bottom: 1px solid var(--border); +} + +.session-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; +} + +.session-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.session-status-dot.idle { background: var(--green); } +.session-status-dot.busy { background: var(--yellow); animation: pulse 1.5s infinite; } +.session-status-dot.error { background: var(--red); } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.session-cost { + font-size: 0.75rem; + color: var(--green); + font-weight: 500; +} + +.session-output { + height: 120px; + padding: 0.75rem; + overflow-y: auto; + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.7rem; + line-height: 1.6; + color: var(--text-dim); + white-space: pre-wrap; +} + +.session-output:empty::after { + content: 'Waiting...'; + color: var(--text-muted); +} + +/* Message styling in output */ +.msg-system { color: var(--text-muted); } +.msg-assistant { color: var(--text); } +.msg-error { color: var(--red); } +.msg-cost { color: var(--green); font-weight: 500; } + +/* Scrollbar */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: var(--bg-dark); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +/* Responsive */ +@media (max-width: 768px) { + .main { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + } + + .control-panel { + border-right: none; + border-bottom: 1px solid var(--border); + } + + .form-row { + grid-template-columns: 1fr; + } + + .header-stats { + display: none; + } + + .sessions-panel { + position: relative; + max-height: none; + } + + .sessions-list { + flex-direction: column; + } + + .session-card { + width: 100%; + } +} + +/* Loading state */ +.loading { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/src/web/server.ts b/src/web/server.ts new file mode 100644 index 00000000..19cfc613 --- /dev/null +++ b/src/web/server.ts @@ -0,0 +1,374 @@ +import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import fastifyStatic from '@fastify/static'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { EventEmitter } from 'node:events'; +import { Session, ClaudeMessage } from '../session.js'; +import { getStore } from '../state-store.js'; +import { v4 as uuidv4 } from 'uuid'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +interface ScheduledRun { + id: string; + prompt: string; + workingDir: string; + durationMinutes: number; + startedAt: number; + endAt: number; + status: 'running' | 'completed' | 'failed' | 'stopped'; + sessionId: string | null; + completedTasks: number; + totalCost: number; + logs: string[]; +} + +export class WebServer extends EventEmitter { + private app: FastifyInstance; + private sessions: Map = new Map(); + private scheduledRuns: Map = new Map(); + private sseClients: Set = new Set(); + private store = getStore(); + private port: number; + + constructor(port: number = 3000) { + super(); + this.port = port; + this.app = Fastify({ logger: false }); + } + + private async setupRoutes(): Promise { + // Serve static files + await this.app.register(fastifyStatic, { + root: join(__dirname, 'public'), + prefix: '/', + }); + + // SSE endpoint for real-time updates + this.app.get('/api/events', (req, reply) => { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }); + + this.sseClients.add(reply); + + // Send initial state + this.sendSSE(reply, 'init', this.getFullState()); + + req.raw.on('close', () => { + this.sseClients.delete(reply); + }); + }); + + // API Routes + this.app.get('/api/status', async () => this.getFullState()); + + // Session management + this.app.get('/api/sessions', async () => this.getSessionsState()); + + this.app.post('/api/sessions', async (req) => { + const body = req.body as { workingDir?: string }; + const workingDir = body.workingDir || process.cwd(); + const session = new Session({ workingDir }); + + this.sessions.set(session.id, session); + this.setupSessionListeners(session); + + this.broadcast('session:created', session.toDetailedState()); + return { success: true, session: session.toDetailedState() }; + }); + + this.app.delete('/api/sessions/:id', async (req) => { + const { id } = req.params as { id: string }; + const session = this.sessions.get(id); + + if (!session) { + return { success: false, error: 'Session not found' }; + } + + await session.stop(); + this.sessions.delete(id); + this.broadcast('session:deleted', { id }); + return { success: true }; + }); + + this.app.get('/api/sessions/:id', async (req) => { + const { id } = req.params as { id: string }; + const session = this.sessions.get(id); + + if (!session) { + return { error: 'Session not found' }; + } + + return session.toDetailedState(); + }); + + this.app.get('/api/sessions/:id/output', async (req) => { + const { id } = req.params as { id: string }; + const session = this.sessions.get(id); + + if (!session) { + return { error: 'Session not found' }; + } + + return { + textOutput: session.textOutput, + messages: session.messages, + errorBuffer: session.errorBuffer, + }; + }); + + // Run prompt in session + this.app.post('/api/sessions/:id/run', async (req) => { + const { id } = req.params as { id: string }; + const { prompt } = req.body as { prompt: string }; + const session = this.sessions.get(id); + + if (!session) { + return { error: 'Session not found' }; + } + + if (session.isBusy()) { + return { error: 'Session is busy' }; + } + + // Run async, don't wait + session.runPrompt(prompt).catch(err => { + this.broadcast('session:error', { id, error: err.message }); + }); + + this.broadcast('session:running', { id, prompt }); + return { success: true, message: 'Prompt started' }; + }); + + // Quick run (create session, run prompt, return result) + this.app.post('/api/run', async (req) => { + const { prompt, workingDir } = req.body as { prompt: string; workingDir?: string }; + const dir = workingDir || process.cwd(); + + const session = new Session({ workingDir: dir }); + this.sessions.set(session.id, session); + this.setupSessionListeners(session); + + this.broadcast('session:created', session.toDetailedState()); + + try { + const result = await session.runPrompt(prompt); + return { success: true, sessionId: session.id, ...result }; + } catch (err) { + return { success: false, sessionId: session.id, error: (err as Error).message }; + } + }); + + // Scheduled runs + this.app.get('/api/scheduled', async () => { + return Array.from(this.scheduledRuns.values()); + }); + + this.app.post('/api/scheduled', async (req) => { + const { prompt, workingDir, durationMinutes } = req.body as { + prompt: string; + workingDir?: string; + durationMinutes: number; + }; + + const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes); + return { success: true, run }; + }); + + this.app.delete('/api/scheduled/:id', async (req) => { + const { id } = req.params as { id: string }; + const run = this.scheduledRuns.get(id); + + if (!run) { + return { success: false, error: 'Scheduled run not found' }; + } + + await this.stopScheduledRun(id); + return { success: true }; + }); + + this.app.get('/api/scheduled/:id', async (req) => { + const { id } = req.params as { id: string }; + const run = this.scheduledRuns.get(id); + + if (!run) { + return { error: 'Scheduled run not found' }; + } + + return run; + }); + } + + private setupSessionListeners(session: Session): void { + session.on('output', (data) => { + this.broadcast('session:output', { id: session.id, data }); + }); + + session.on('message', (msg: ClaudeMessage) => { + this.broadcast('session:message', { id: session.id, message: msg }); + }); + + session.on('error', (error) => { + this.broadcast('session:error', { id: session.id, error }); + }); + + session.on('completion', (result, cost) => { + this.broadcast('session:completion', { id: session.id, result, cost }); + this.broadcast('session:updated', session.toDetailedState()); + }); + + session.on('exit', (code) => { + this.broadcast('session:exit', { id: session.id, code }); + this.broadcast('session:updated', session.toDetailedState()); + }); + } + + private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise { + const id = uuidv4(); + const now = Date.now(); + + const run: ScheduledRun = { + id, + prompt, + workingDir, + durationMinutes, + startedAt: now, + endAt: now + durationMinutes * 60 * 1000, + status: 'running', + sessionId: null, + completedTasks: 0, + totalCost: 0, + logs: [`[${new Date().toISOString()}] Scheduled run started`], + }; + + this.scheduledRuns.set(id, run); + this.broadcast('scheduled:created', run); + + // Start the run loop + this.runScheduledLoop(id); + + return run; + } + + private async runScheduledLoop(runId: string): Promise { + const run = this.scheduledRuns.get(runId); + if (!run || run.status !== 'running') return; + + const addLog = (msg: string) => { + run.logs.push(`[${new Date().toISOString()}] ${msg}`); + this.broadcast('scheduled:log', { id: runId, log: run.logs[run.logs.length - 1] }); + }; + + while (Date.now() < run.endAt && run.status === 'running') { + try { + // Create a session for this iteration + const session = new Session({ workingDir: run.workingDir }); + this.sessions.set(session.id, session); + this.setupSessionListeners(session); + run.sessionId = session.id; + + addLog(`Starting task iteration with session ${session.id.slice(0, 8)}`); + this.broadcast('scheduled:updated', run); + + // Run the prompt + const timeRemaining = Math.round((run.endAt - Date.now()) / 60000); + const enhancedPrompt = `${run.prompt}\n\nNote: You have approximately ${timeRemaining} minutes remaining in this scheduled run. Work efficiently.`; + + const result = await session.runPrompt(enhancedPrompt); + run.completedTasks++; + run.totalCost += result.cost; + + addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`); + this.broadcast('scheduled:updated', run); + + // Small pause between iterations + await new Promise(r => setTimeout(r, 2000)); + } catch (err) { + addLog(`Error: ${(err as Error).message}`); + this.broadcast('scheduled:updated', run); + // Continue despite errors + await new Promise(r => setTimeout(r, 5000)); + } + } + + if (run.status === 'running') { + run.status = 'completed'; + addLog(`Scheduled run completed. Total tasks: ${run.completedTasks}, Total cost: $${run.totalCost.toFixed(4)}`); + } + + this.broadcast('scheduled:completed', run); + } + + private async stopScheduledRun(id: string): Promise { + const run = this.scheduledRuns.get(id); + if (!run) return; + + run.status = 'stopped'; + run.logs.push(`[${new Date().toISOString()}] Run stopped by user`); + + if (run.sessionId) { + const session = this.sessions.get(run.sessionId); + if (session) { + await session.stop(); + } + } + + this.broadcast('scheduled:stopped', run); + } + + private getSessionsState() { + return Array.from(this.sessions.values()).map(s => s.toDetailedState()); + } + + private getFullState() { + return { + sessions: this.getSessionsState(), + scheduledRuns: Array.from(this.scheduledRuns.values()), + timestamp: Date.now(), + }; + } + + private sendSSE(reply: FastifyReply, event: string, data: unknown): void { + try { + reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + } catch { + this.sseClients.delete(reply); + } + } + + private broadcast(event: string, data: unknown): void { + for (const client of this.sseClients) { + this.sendSSE(client, event, data); + } + } + + async start(): Promise { + await this.setupRoutes(); + await this.app.listen({ port: this.port, host: '0.0.0.0' }); + console.log(`Claudeman web interface running at http://localhost:${this.port}`); + } + + async stop(): Promise { + // Stop all sessions + for (const session of this.sessions.values()) { + await session.stop(); + } + + // Stop all scheduled runs + for (const [id] of this.scheduledRuns) { + await this.stopScheduledRun(id); + } + + await this.app.close(); + } +} + +export async function startWebServer(port: number = 3000): Promise { + const server = new WebServer(port); + await server.start(); + return server; +}