diff --git a/README.md b/README.md index 61a255b0..13751717 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,79 @@ Click the **Stop** button at any time. The runner will: You can run separate batch processes in different Maestro sessions simultaneously. Each session maintains its own independent batch state. With Git worktrees enabled, you can work on the main branch while Auto Run operates in an isolated worktree. +## Command Line Interface + +Maestro includes a CLI tool (`maestro-playbook`) for running playbooks from the command line, cron jobs, or CI/CD pipelines. The CLI is a standalone binary that requires no additional dependencies. + +### Installation + +The CLI binary is bundled with Maestro. After installation, create a symlink to add it to your PATH: + +```bash +# macOS (after installing Maestro.app) +sudo ln -sf "/Applications/Maestro.app/Contents/Resources/maestro-playbook" /usr/local/bin/maestro-playbook + +# Windows (run as Administrator in PowerShell) +# The binary is located at: C:\Program Files\Maestro\resources\maestro-playbook.exe + +# Linux (AppImage - extract first, or use deb/rpm which installs to /opt) +sudo ln -sf "/opt/Maestro/resources/maestro-playbook" /usr/local/bin/maestro-playbook +``` + +### Usage + +```bash +# List all groups +maestro-playbook list groups + +# List all agents/sessions +maestro-playbook list agents +maestro-playbook list agents --group + +# List playbooks for a session +maestro-playbook list playbooks --session + +# Run a playbook (streams JSONL to stdout) +maestro-playbook run --session --playbook + +# Dry run (shows what would be executed) +maestro-playbook run --session --playbook --dry-run + +# Run without writing to history +maestro-playbook run --session --playbook --no-history +``` + +### JSONL Output + +All commands output JSONL (JSON Lines) format for easy parsing: + +```bash +# List groups +maestro-playbook list groups +{"type":"group","id":"abc123","name":"Frontend","emoji":"...","timestamp":...} + +# Running a playbook streams events +maestro-playbook run -s -p +{"type":"start","timestamp":...,"playbook":{...},"session":{...}} +{"type":"document_start","timestamp":...,"document":"tasks.md","taskCount":5} +{"type":"task_start","timestamp":...,"document":"tasks.md","taskIndex":0} +{"type":"task_complete","timestamp":...,"success":true,"summary":"...","elapsedMs":8000} +{"type":"document_complete","timestamp":...,"tasksCompleted":5} +{"type":"complete","timestamp":...,"totalTasksCompleted":5,"totalElapsedMs":60000} +``` + +### Scheduling with Cron + +```bash +# Run a playbook every hour +0 * * * * /usr/local/bin/maestro-playbook run -s -p >> /var/log/maestro.jsonl 2>&1 +``` + +### Requirements + +- Claude Code CLI must be installed and in PATH +- Maestro config files must exist (created automatically when you use the GUI) + ## Configuration Settings are stored in: diff --git a/package-lock.json b/package-lock.json index 84aeee62..727761bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "ansi-to-html": "^0.7.2", "archiver": "^7.0.1", "canvas-confetti": "^1.9.4", + "commander": "^14.0.2", "diff": "^8.0.2", "dompurify": "^3.3.0", "electron-store": "^8.1.0", @@ -37,6 +38,9 @@ "remark-gfm": "^4.0.1", "ws": "^8.16.0" }, + "bin": { + "maestro-playbook": "dist/cli/cli/index.js" + }, "devDependencies": { "@types/adm-zip": "^0.5.7", "@types/archiver": "^7.0.0", @@ -48,6 +52,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "@types/ws": "^8.5.10", "@vitejs/plugin-react": "^4.2.1", + "@yao-pkg/pkg": "^6.10.1", "autoprefixer": "^10.4.16", "canvas": "^3.2.0", "concurrently": "^8.2.2", @@ -483,6 +488,16 @@ "concat-map": "0.0.1" } }, + "node_modules/@electron/asar/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/@electron/asar/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -1456,6 +1471,29 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/fs-minipass/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2650,6 +2688,224 @@ "node": ">=10.0.0" } }, + "node_modules/@yao-pkg/pkg": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-6.10.1.tgz", + "integrity": "sha512-M/eqDg0Iir2nmyZ06Q9ospIPv1Yk7K1du5iLiaYrfMogQcI6bqf82A026MVYngyLH8jZsquZvjNAbvgbW4Uwkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", + "@yao-pkg/pkg-fetch": "3.5.30", + "into-stream": "^6.0.0", + "minimist": "^1.2.6", + "multistream": "^4.1.0", + "picocolors": "^1.1.0", + "picomatch": "^4.0.2", + "prebuild-install": "^7.1.1", + "resolve": "^1.22.10", + "stream-meter": "^1.0.4", + "tar": "^7.4.3", + "tinyglobby": "^0.2.11", + "unzipper": "^0.12.3" + }, + "bin": { + "pkg": "lib-es5/bin.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@yao-pkg/pkg-fetch": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.30.tgz", + "integrity": "sha512-OrXQlsR3vE/IvwXSk8R5ETYbcxAFtUPmLkeepbG+ArN82TvlIwcUJ65tEWxLG3Tl89VRbmOupuhkXfmuaO05+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.6", + "picocolors": "^1.1.0", + "progress": "^2.0.3", + "semver": "^7.3.5", + "tar-fs": "^3.1.1", + "yargs": "^16.2.0" + }, + "bin": { + "pkg-fetch": "lib-es5/bin.js" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/@yao-pkg/pkg/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@yao-pkg/pkg/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@yao-pkg/pkg/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@yao-pkg/pkg/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@yao-pkg/pkg/node_modules/tar": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@yao-pkg/pkg/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -3368,6 +3624,88 @@ } } }, + "node_modules/bare-fs": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4162,13 +4500,12 @@ } }, "node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "dev": true, + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=20" } }, "node_modules/compare-version": { @@ -5541,6 +5878,49 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/duplexify": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", @@ -6671,6 +7051,50 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "node_modules/from2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/from2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/from2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -7440,6 +7864,23 @@ "node": ">=12" } }, + "node_modules/into-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-6.0.0.tgz", + "integrity": "sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "from2": "^2.3.0", + "p-is-promise": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -9509,6 +9950,31 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multistream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/multistream/-/multistream-4.1.0.tgz", + "integrity": "sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==", + "dev": true, + "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", + "dependencies": { + "once": "^1.4.0", + "readable-stream": "^3.6.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -9620,6 +10086,27 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-gyp": { "version": "9.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", @@ -9671,6 +10158,13 @@ "node": ">=10" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-pty": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", @@ -9866,6 +10360,16 @@ "node": ">=8" } }, + "node_modules/p-is-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", + "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -11666,6 +12170,49 @@ "node": ">= 0.8" } }, + "node_modules/stream-meter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", + "integrity": "sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.1.4" + } + }, + "node_modules/stream-meter/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/stream-meter/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/stream-meter/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", @@ -12182,6 +12729,13 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -12420,6 +12974,58 @@ "node": ">= 4.0.0" } }, + "node_modules/unzipper": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "^11.2.0", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/unzipper/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/unzipper/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -12675,6 +13281,24 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 3cc5260f..0c0ea77c 100644 --- a/package.json +++ b/package.json @@ -12,20 +12,35 @@ "type": "git", "url": "https://github.com/yourusername/maestro.git" }, + "bin": { + "maestro-playbook": "./dist/cli/cli/index.js" + }, + "pkg": { + "scripts": "dist/cli/**/*.js", + "targets": [ + "node20-macos-arm64", + "node20-macos-x64", + "node20-win-x64", + "node20-linux-x64" + ], + "outputPath": "dist/cli/bin" + }, "scripts": { "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", "dev:main": "tsc -p tsconfig.main.json && NODE_ENV=development electron .", "dev:renderer": "vite", "dev:web": "vite --config vite.config.web.mts", "generate:pwa-icons": "node scripts/generate-pwa-icons.mjs", - "build": "npm run build:main && npm run build:renderer && npm run build:web", + "build": "npm run build:main && npm run build:renderer && npm run build:web && npm run build:cli", "build:main": "tsc -p tsconfig.main.json", + "build:cli": "tsc -p tsconfig.cli.json", + "build:cli:bin": "npm run build:cli && pkg dist/cli/cli/index.js --config package.json", "build:renderer": "vite build", "build:web": "vite build --config vite.config.web.mts", - "package": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --mac --win --linux", - "package:mac": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --mac", - "package:win": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --win", - "package:linux": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --linux", + "package": "node scripts/set-version.mjs npm run build && npm run build:cli:bin && node scripts/set-version.mjs electron-builder --mac --win --linux", + "package:mac": "node scripts/set-version.mjs npm run build && npm run build:cli:bin && node scripts/set-version.mjs electron-builder --mac", + "package:win": "node scripts/set-version.mjs npm run build && npm run build:cli:bin && node scripts/set-version.mjs electron-builder --win", + "package:linux": "node scripts/set-version.mjs npm run build && npm run build:cli:bin && node scripts/set-version.mjs electron-builder --linux", "start": "electron .", "clean": "rm -rf dist release node_modules/.vite", "postinstall": "electron-rebuild -f -w node-pty" @@ -60,7 +75,13 @@ ] } ], - "icon": "build/icon.icns" + "icon": "build/icon.icns", + "extraResources": [ + { + "from": "dist/cli/bin/maestro-macos-${arch}", + "to": "maestro-playbook" + } + ] }, "win": { "target": [ @@ -77,7 +98,13 @@ ] } ], - "icon": "build/icon.ico" + "icon": "build/icon.ico", + "extraResources": [ + { + "from": "dist/cli/bin/maestro-win-x64.exe", + "to": "maestro-playbook.exe" + } + ] }, "linux": { "target": [ @@ -86,7 +113,13 @@ "rpm" ], "category": "Development", - "icon": "build/icon.png" + "icon": "build/icon.png", + "extraResources": [ + { + "from": "dist/cli/bin/maestro-linux-x64", + "to": "maestro-playbook" + } + ] }, "nsis": { "oneClick": false, @@ -105,6 +138,7 @@ "ansi-to-html": "^0.7.2", "archiver": "^7.0.1", "canvas-confetti": "^1.9.4", + "commander": "^14.0.2", "diff": "^8.0.2", "dompurify": "^3.3.0", "electron-store": "^8.1.0", @@ -132,6 +166,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "@types/ws": "^8.5.10", "@vitejs/plugin-react": "^4.2.1", + "@yao-pkg/pkg": "^6.10.1", "autoprefixer": "^10.4.16", "canvas": "^3.2.0", "concurrently": "^8.2.2", diff --git a/src/cli/commands/list-agents.ts b/src/cli/commands/list-agents.ts new file mode 100644 index 00000000..a509d125 --- /dev/null +++ b/src/cli/commands/list-agents.ts @@ -0,0 +1,36 @@ +// List agents command +// Lists all agents/sessions from Maestro storage + +import { readSessions, getSessionsByGroup } from '../services/storage'; +import { emitAgent, emitError } from '../output/jsonl'; + +interface ListAgentsOptions { + group?: string; +} + +export function listAgents(options: ListAgentsOptions): void { + try { + let sessions; + + if (options.group) { + sessions = getSessionsByGroup(options.group); + } else { + sessions = readSessions(); + } + + for (const session of sessions) { + emitAgent({ + id: session.id, + name: session.name, + toolType: session.toolType, + cwd: session.cwd, + groupId: session.groupId, + autoRunFolderPath: session.autoRunFolderPath, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + emitError(`Failed to list agents: ${message}`, 'STORAGE_ERROR'); + process.exit(1); + } +} diff --git a/src/cli/commands/list-groups.ts b/src/cli/commands/list-groups.ts new file mode 100644 index 00000000..251472bf --- /dev/null +++ b/src/cli/commands/list-groups.ts @@ -0,0 +1,24 @@ +// List groups command +// Lists all session groups from Maestro storage + +import { readGroups } from '../services/storage'; +import { emitGroup, emitError } from '../output/jsonl'; + +export function listGroups(): void { + try { + const groups = readGroups(); + + for (const group of groups) { + emitGroup({ + id: group.id, + name: group.name, + emoji: group.emoji, + collapsed: group.collapsed, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + emitError(`Failed to list groups: ${message}`, 'STORAGE_ERROR'); + process.exit(1); + } +} diff --git a/src/cli/commands/list-playbooks.ts b/src/cli/commands/list-playbooks.ts new file mode 100644 index 00000000..6ed62857 --- /dev/null +++ b/src/cli/commands/list-playbooks.ts @@ -0,0 +1,30 @@ +// List playbooks command +// Lists all playbooks for a given session + +import { readPlaybooks } from '../services/playbooks'; +import { emitPlaybook, emitError } from '../output/jsonl'; + +interface ListPlaybooksOptions { + session: string; +} + +export function listPlaybooks(options: ListPlaybooksOptions): void { + try { + const playbooks = readPlaybooks(options.session); + + for (const playbook of playbooks) { + emitPlaybook({ + id: playbook.id, + name: playbook.name, + sessionId: options.session, + documents: playbook.documents.map(d => d.filename), + loopEnabled: playbook.loopEnabled, + maxLoops: playbook.maxLoops, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + emitError(`Failed to list playbooks: ${message}`, 'STORAGE_ERROR'); + process.exit(1); + } +} diff --git a/src/cli/commands/run-playbook.ts b/src/cli/commands/run-playbook.ts new file mode 100644 index 00000000..eaaeba00 --- /dev/null +++ b/src/cli/commands/run-playbook.ts @@ -0,0 +1,61 @@ +// Run playbook command +// Executes a playbook and streams JSONL events to stdout + +import { getSessionById } from '../services/storage'; +import { getPlaybook } from '../services/playbooks'; +import { runPlaybook as executePlaybook } from '../services/batch-processor'; +import { detectClaude } from '../services/agent-spawner'; +import { emitError } from '../output/jsonl'; + +interface RunPlaybookOptions { + session: string; + playbook: string; + dryRun?: boolean; + noHistory?: boolean; +} + +export async function runPlaybook(options: RunPlaybookOptions): Promise { + try { + // Check if Claude is available + const claude = await detectClaude(); + if (!claude.available) { + emitError('Claude Code not found. Please install claude-code CLI.', 'CLAUDE_NOT_FOUND'); + process.exit(1); + } + + // Get session + const session = getSessionById(options.session); + if (!session) { + emitError(`Session not found: ${options.session}`, 'SESSION_NOT_FOUND'); + process.exit(1); + } + + // Get playbook + const playbook = getPlaybook(options.session, options.playbook); + if (!playbook) { + emitError(`Playbook not found: ${options.playbook}`, 'PLAYBOOK_NOT_FOUND'); + process.exit(1); + } + + // Determine Auto Run folder path + const folderPath = session.autoRunFolderPath; + if (!folderPath) { + emitError('Session does not have an Auto Run folder configured', 'NO_AUTORUN_FOLDER'); + process.exit(1); + } + + // Execute playbook and stream events + const generator = executePlaybook(session, playbook, folderPath, { + dryRun: options.dryRun, + writeHistory: !options.noHistory, + }); + + for await (const event of generator) { + console.log(JSON.stringify(event)); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + emitError(`Failed to run playbook: ${message}`, 'EXECUTION_ERROR'); + process.exit(1); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 00000000..58f9e6be --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Maestro Playbook CLI +// Run Maestro playbooks from the command line + +import { Command } from 'commander'; +import { listGroups } from './commands/list-groups'; +import { listAgents } from './commands/list-agents'; +import { listPlaybooks } from './commands/list-playbooks'; +import { runPlaybook } from './commands/run-playbook'; + +const program = new Command(); + +program + .name('maestro-playbook') + .description('CLI for running Maestro playbooks') + .version('0.1.0'); + +// List commands +const list = program.command('list').description('List resources'); + +list + .command('groups') + .description('List all session groups') + .action(listGroups); + +list + .command('agents') + .description('List all agents/sessions') + .option('-g, --group ', 'Filter by group ID') + .action(listAgents); + +list + .command('playbooks') + .description('List playbooks for a session') + .requiredOption('-s, --session ', 'Session ID') + .action(listPlaybooks); + +// Run command +program + .command('run') + .description('Run a playbook') + .requiredOption('-s, --session ', 'Session ID') + .requiredOption('-p, --playbook ', 'Playbook ID') + .option('--dry-run', 'Show what would be executed without running') + .option('--no-history', 'Do not write history entries') + .action(runPlaybook); + +program.parse(); diff --git a/src/cli/output/jsonl.ts b/src/cli/output/jsonl.ts new file mode 100644 index 00000000..4ecdf831 --- /dev/null +++ b/src/cli/output/jsonl.ts @@ -0,0 +1,252 @@ +// JSONL output helper for CLI +// Outputs machine-parseable JSON lines to stdout + +import type { UsageStats } from '../../shared/types'; + +// Base event interface - all events have a type and timestamp +export interface JsonlEvent { + type: string; + timestamp: number; + [key: string]: unknown; +} + +// Event types for playbook execution +export interface StartEvent extends JsonlEvent { + type: 'start'; + playbook: { + id: string; + name: string; + }; + session: { + id: string; + name: string; + cwd: string; + }; +} + +export interface DocumentStartEvent extends JsonlEvent { + type: 'document_start'; + document: string; + index: number; + taskCount: number; +} + +export interface TaskStartEvent extends JsonlEvent { + type: 'task_start'; + document: string; + taskIndex: number; +} + +export interface TaskCompleteEvent extends JsonlEvent { + type: 'task_complete'; + document: string; + taskIndex: number; + success: boolean; + summary: string; + fullResponse?: string; + elapsedMs: number; + usageStats?: UsageStats; + claudeSessionId?: string; +} + +export interface DocumentCompleteEvent extends JsonlEvent { + type: 'document_complete'; + document: string; + tasksCompleted: number; +} + +export interface LoopCompleteEvent extends JsonlEvent { + type: 'loop_complete'; + iteration: number; + tasksCompleted: number; + elapsedMs: number; + usageStats?: UsageStats; +} + +export interface CompleteEvent extends JsonlEvent { + type: 'complete'; + success: boolean; + totalTasksCompleted: number; + totalElapsedMs: number; + totalCost?: number; +} + +export interface ErrorEvent extends JsonlEvent { + type: 'error'; + message: string; + code?: string; +} + +// List command events +export interface GroupEvent extends JsonlEvent { + type: 'group'; + id: string; + name: string; + emoji: string; + collapsed: boolean; +} + +export interface AgentEvent extends JsonlEvent { + type: 'agent'; + id: string; + name: string; + toolType: string; + cwd: string; + groupId?: string; + autoRunFolderPath?: string; +} + +export interface PlaybookEvent extends JsonlEvent { + type: 'playbook'; + id: string; + name: string; + sessionId: string; + documents: string[]; + loopEnabled: boolean; + maxLoops?: number | null; +} + +// Union type of all events +export type CliEvent = + | StartEvent + | DocumentStartEvent + | TaskStartEvent + | TaskCompleteEvent + | DocumentCompleteEvent + | LoopCompleteEvent + | CompleteEvent + | ErrorEvent + | GroupEvent + | AgentEvent + | PlaybookEvent; + +/** + * Emit a JSONL event to stdout + */ +export function emitJsonl(event: { type: string; [key: string]: unknown }): void { + const fullEvent = { + ...event, + timestamp: Date.now(), + }; + console.log(JSON.stringify(fullEvent)); +} + +/** + * Emit an error event + */ +export function emitError(message: string, code?: string): void { + emitJsonl({ type: 'error', message, code }); +} + +/** + * Emit a start event + */ +export function emitStart(playbook: { id: string; name: string }, session: { id: string; name: string; cwd: string }): void { + emitJsonl({ type: 'start', playbook, session }); +} + +/** + * Emit a document start event + */ +export function emitDocumentStart(document: string, index: number, taskCount: number): void { + emitJsonl({ type: 'document_start', document, index, taskCount }); +} + +/** + * Emit a task start event + */ +export function emitTaskStart(document: string, taskIndex: number): void { + emitJsonl({ type: 'task_start', document, taskIndex }); +} + +/** + * Emit a task complete event + */ +export function emitTaskComplete( + document: string, + taskIndex: number, + success: boolean, + summary: string, + elapsedMs: number, + options?: { + fullResponse?: string; + usageStats?: UsageStats; + claudeSessionId?: string; + } +): void { + emitJsonl({ + type: 'task_complete', + document, + taskIndex, + success, + summary, + elapsedMs, + ...options, + }); +} + +/** + * Emit a document complete event + */ +export function emitDocumentComplete(document: string, tasksCompleted: number): void { + emitJsonl({ type: 'document_complete', document, tasksCompleted }); +} + +/** + * Emit a loop complete event + */ +export function emitLoopComplete( + iteration: number, + tasksCompleted: number, + elapsedMs: number, + usageStats?: UsageStats +): void { + emitJsonl({ type: 'loop_complete', iteration, tasksCompleted, elapsedMs, usageStats }); +} + +/** + * Emit a complete event + */ +export function emitComplete( + success: boolean, + totalTasksCompleted: number, + totalElapsedMs: number, + totalCost?: number +): void { + emitJsonl({ type: 'complete', success, totalTasksCompleted, totalElapsedMs, totalCost }); +} + +/** + * Emit a group event (for list groups) + */ +export function emitGroup(group: { id: string; name: string; emoji: string; collapsed: boolean }): void { + emitJsonl({ type: 'group', ...group }); +} + +/** + * Emit an agent event (for list agents) + */ +export function emitAgent(agent: { + id: string; + name: string; + toolType: string; + cwd: string; + groupId?: string; + autoRunFolderPath?: string; +}): void { + emitJsonl({ type: 'agent', ...agent }); +} + +/** + * Emit a playbook event (for list playbooks) + */ +export function emitPlaybook(playbook: { + id: string; + name: string; + sessionId: string; + documents: string[]; + loopEnabled: boolean; + maxLoops?: number | null; +}): void { + emitJsonl({ type: 'playbook', ...playbook }); +} diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts new file mode 100644 index 00000000..54127311 --- /dev/null +++ b/src/cli/services/agent-spawner.ts @@ -0,0 +1,259 @@ +// Agent spawner service for CLI +// Spawns Claude Code and parses its output + +import { spawn, SpawnOptions } from 'child_process'; +import * as os from 'os'; +import * as fs from 'fs'; +import type { UsageStats } from '../../shared/types'; + +// Claude Code command and arguments (same as Electron app) +const CLAUDE_COMMAND = 'claude'; +const CLAUDE_ARGS = ['--print', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions']; + +// Result from spawning an agent +export interface AgentResult { + success: boolean; + response?: string; + claudeSessionId?: string; + usageStats?: UsageStats; + error?: string; +} + +/** + * Build an expanded PATH that includes common binary installation locations + */ +function getExpandedPath(): string { + const home = os.homedir(); + const additionalPaths = [ + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/bin', + '/usr/local/sbin', + `${home}/.local/bin`, + `${home}/.npm-global/bin`, + `${home}/bin`, + `${home}/.claude/local`, + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', + ]; + + const currentPath = process.env.PATH || ''; + const pathParts = currentPath.split(':'); + + for (const p of additionalPaths) { + if (!pathParts.includes(p)) { + pathParts.unshift(p); + } + } + + return pathParts.join(':'); +} + +/** + * Check if Claude Code is available + */ +export async function detectClaude(): Promise<{ available: boolean; path?: string }> { + return new Promise((resolve) => { + const env = { ...process.env, PATH: getExpandedPath() }; + + const which = spawn('which', [CLAUDE_COMMAND], { env }); + let stdout = ''; + + which.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + which.on('close', (code) => { + if (code === 0 && stdout.trim()) { + resolve({ available: true, path: stdout.trim() }); + } else { + resolve({ available: false }); + } + }); + + which.on('error', () => { + resolve({ available: false }); + }); + }); +} + +/** + * Spawn Claude Code with a prompt and return the result + */ +export async function spawnAgent( + cwd: string, + prompt: string, + claudeSessionId?: string +): Promise { + return new Promise((resolve) => { + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: getExpandedPath(), + }; + + // Build args: base args + optional resume + prompt + const args = [...CLAUDE_ARGS]; + + if (claudeSessionId) { + args.push('--resume', claudeSessionId); + } + + // Add prompt as positional argument + args.push('--', prompt); + + const options: SpawnOptions = { + cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }; + + const child = spawn(CLAUDE_COMMAND, args, options); + + let jsonBuffer = ''; + let result: string | undefined; + let sessionId: string | undefined; + let usageStats: UsageStats | undefined; + let resultEmitted = false; + let sessionIdEmitted = false; + + // Handle stdout - parse stream-json format + child.stdout?.on('data', (data: Buffer) => { + jsonBuffer += data.toString(); + + // Process complete lines + const lines = jsonBuffer.split('\n'); + jsonBuffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + + try { + const msg = JSON.parse(line); + + // Capture result (only once) + if (msg.type === 'result' && msg.result && !resultEmitted) { + resultEmitted = true; + result = msg.result; + } + + // Capture session_id (only once) + if (msg.session_id && !sessionIdEmitted) { + sessionIdEmitted = true; + sessionId = msg.session_id; + } + + // Extract usage statistics + if (msg.modelUsage || msg.usage || msg.total_cost_usd !== undefined) { + const usage = msg.usage || {}; + + let aggregatedInputTokens = 0; + let aggregatedOutputTokens = 0; + let aggregatedCacheReadTokens = 0; + let aggregatedCacheCreationTokens = 0; + let contextWindow = 200000; + + if (msg.modelUsage) { + for (const modelStats of Object.values(msg.modelUsage) as Record[]) { + aggregatedInputTokens += modelStats.inputTokens || 0; + aggregatedOutputTokens += modelStats.outputTokens || 0; + aggregatedCacheReadTokens += modelStats.cacheReadInputTokens || 0; + aggregatedCacheCreationTokens += modelStats.cacheCreationInputTokens || 0; + if (modelStats.contextWindow && modelStats.contextWindow > contextWindow) { + contextWindow = modelStats.contextWindow; + } + } + } + + if (aggregatedInputTokens === 0 && aggregatedOutputTokens === 0) { + aggregatedInputTokens = usage.input_tokens || 0; + aggregatedOutputTokens = usage.output_tokens || 0; + aggregatedCacheReadTokens = usage.cache_read_input_tokens || 0; + aggregatedCacheCreationTokens = usage.cache_creation_input_tokens || 0; + } + + usageStats = { + inputTokens: aggregatedInputTokens, + outputTokens: aggregatedOutputTokens, + cacheReadInputTokens: aggregatedCacheReadTokens, + cacheCreationInputTokens: aggregatedCacheCreationTokens, + totalCostUsd: msg.total_cost_usd || 0, + contextWindow, + }; + } + } catch { + // Ignore non-JSON lines + } + } + }); + + // Collect stderr for error reporting + let stderr = ''; + child.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + // Close stdin immediately + child.stdin?.end(); + + // Handle completion + child.on('close', (code) => { + if (code === 0 && result) { + resolve({ + success: true, + response: result, + claudeSessionId: sessionId, + usageStats, + }); + } else { + resolve({ + success: false, + error: stderr || `Process exited with code ${code}`, + claudeSessionId: sessionId, + usageStats, + }); + } + }); + + child.on('error', (error) => { + resolve({ + success: false, + error: `Failed to spawn Claude: ${error.message}`, + }); + }); + }); +} + +/** + * Read a markdown document and count unchecked tasks + */ +export function readDocAndCountTasks(folderPath: string, filename: string): { content: string; taskCount: number } { + const filePath = `${folderPath}/${filename}.md`; + + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const matches = content.match(/^[\s]*-\s*\[\s*\]\s*.+$/gm); + return { + content, + taskCount: matches ? matches.length : 0, + }; + } catch (error) { + return { content: '', taskCount: 0 }; + } +} + +/** + * Uncheck all markdown checkboxes in content (for reset-on-completion) + */ +export function uncheckAllTasks(content: string): string { + return content.replace(/^(\s*-\s*)\[x\]/gim, '$1[ ]'); +} + +/** + * Write content to a document + */ +export function writeDoc(folderPath: string, filename: string, content: string): void { + const filePath = `${folderPath}/${filename}`; + fs.writeFileSync(filePath, content, 'utf-8'); +} diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts new file mode 100644 index 00000000..65f72e6e --- /dev/null +++ b/src/cli/services/batch-processor.ts @@ -0,0 +1,360 @@ +// Batch processor service for CLI +// Executes playbooks and yields JSONL events + +import type { Playbook, SessionInfo, UsageStats, HistoryEntry } from '../../shared/types'; +import type { JsonlEvent } from '../output/jsonl'; +import { + spawnAgent, + readDocAndCountTasks, + uncheckAllTasks, + writeDoc, +} from './agent-spawner'; +import { addHistoryEntry } from './storage'; + +// Synopsis prompt for batch tasks +const BATCH_SYNOPSIS_PROMPT = `Provide a brief synopsis of what you just accomplished in this task using this exact format: + +**Summary:** [1-2 sentences describing the key outcome] + +**Details:** [A paragraph with more specifics about what was done, files changed, etc.] + +Rules: +- Be specific about what was actually accomplished, not what was attempted. +- Focus only on meaningful work that was done. Omit filler phrases like "the task is complete", "no further action needed", "everything is working", etc. +- If nothing meaningful was accomplished, respond with only: **Summary:** No changes made.`; + +/** + * Parse a synopsis response into short summary and full synopsis + */ +function parseSynopsis(response: string): { shortSummary: string; fullSynopsis: string } { + const clean = response + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/─+/g, '') + .replace(/[│┌┐└┘├┤┬┴┼]/g, '') + .trim(); + + const summaryMatch = clean.match(/\*\*Summary:\*\*\s*(.+?)(?=\*\*Details:\*\*|$)/is); + const detailsMatch = clean.match(/\*\*Details:\*\*\s*(.+?)$/is); + + const shortSummary = summaryMatch?.[1]?.trim() || clean.split('\n')[0]?.trim() || 'Task completed'; + const details = detailsMatch?.[1]?.trim() || ''; + + const fullSynopsis = details ? `${shortSummary}\n\n${details}` : shortSummary; + + return { shortSummary, fullSynopsis }; +} + +/** + * Generate a UUID (simple implementation without uuid package) + */ +function generateUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * Process a playbook and yield JSONL events + */ +export async function* runPlaybook( + session: SessionInfo, + playbook: Playbook, + folderPath: string, + options: { + dryRun?: boolean; + writeHistory?: boolean; + } = {} +): AsyncGenerator { + const { dryRun = false, writeHistory = true } = options; + const batchStartTime = Date.now(); + + // Emit start event + yield { + type: 'start', + timestamp: Date.now(), + playbook: { id: playbook.id, name: playbook.name }, + session: { id: session.id, name: session.name, cwd: session.cwd }, + }; + + // Calculate initial total tasks + let initialTotalTasks = 0; + for (const doc of playbook.documents) { + const { taskCount } = readDocAndCountTasks(folderPath, doc.filename); + initialTotalTasks += taskCount; + } + + if (initialTotalTasks === 0) { + yield { + type: 'error', + timestamp: Date.now(), + message: 'No unchecked tasks found in any documents', + code: 'NO_TASKS', + }; + return; + } + + if (dryRun) { + // Dry run - just show what would be executed + yield { + type: 'complete', + timestamp: Date.now(), + success: true, + totalTasksCompleted: 0, + totalElapsedMs: 0, + dryRun: true, + wouldProcess: initialTotalTasks, + }; + return; + } + + // Track totals + let totalCompletedTasks = 0; + let totalCost = 0; + let loopIteration = 0; + + // Per-loop tracking + let loopStartTime = Date.now(); + let loopTasksCompleted = 0; + let loopTotalInputTokens = 0; + let loopTotalOutputTokens = 0; + let loopTotalCost = 0; + + // Main processing loop + while (true) { + let anyTasksProcessedThisIteration = false; + + // Process each document in order + for (let docIndex = 0; docIndex < playbook.documents.length; docIndex++) { + const docEntry = playbook.documents[docIndex]; + + // Read document and count tasks + let { taskCount: remainingTasks } = readDocAndCountTasks(folderPath, docEntry.filename); + + // Skip documents with no tasks + if (remainingTasks === 0) { + continue; + } + + // Emit document start event + yield { + type: 'document_start', + timestamp: Date.now(), + document: docEntry.filename, + index: docIndex, + taskCount: remainingTasks, + }; + + let docTasksCompleted = 0; + let taskIndex = 0; + + // Process tasks in this document + while (remainingTasks > 0) { + // Emit task start + yield { + type: 'task_start', + timestamp: Date.now(), + document: docEntry.filename, + taskIndex, + }; + + const taskStartTime = Date.now(); + + // Replace $$SCRATCHPAD$$ placeholder with actual document path + const docFilePath = `${folderPath}/${docEntry.filename}.md`; + const finalPrompt = playbook.prompt.replace(/\$\$SCRATCHPAD\$\$/g, docFilePath); + + // Spawn agent + const result = await spawnAgent(session.cwd, finalPrompt); + + const elapsedMs = Date.now() - taskStartTime; + + // Re-read document to get new task count + const { taskCount: newRemainingTasks } = readDocAndCountTasks(folderPath, docEntry.filename); + const tasksCompletedThisRun = remainingTasks - newRemainingTasks; + + // Update counters + docTasksCompleted += tasksCompletedThisRun; + totalCompletedTasks += tasksCompletedThisRun; + loopTasksCompleted += tasksCompletedThisRun; + anyTasksProcessedThisIteration = true; + + // Track usage + if (result.usageStats) { + loopTotalInputTokens += result.usageStats.inputTokens || 0; + loopTotalOutputTokens += result.usageStats.outputTokens || 0; + loopTotalCost += result.usageStats.totalCostUsd || 0; + totalCost += result.usageStats.totalCostUsd || 0; + } + + // Generate synopsis + let shortSummary = `[${docEntry.filename}] Task completed`; + let fullSynopsis = shortSummary; + + if (result.success && result.claudeSessionId) { + // Request synopsis from the agent + const synopsisResult = await spawnAgent( + session.cwd, + BATCH_SYNOPSIS_PROMPT, + result.claudeSessionId + ); + + if (synopsisResult.success && synopsisResult.response) { + const parsed = parseSynopsis(synopsisResult.response); + shortSummary = parsed.shortSummary; + fullSynopsis = parsed.fullSynopsis; + } + } else if (!result.success) { + shortSummary = `[${docEntry.filename}] Task failed`; + fullSynopsis = result.error || shortSummary; + } + + // Emit task complete event + yield { + type: 'task_complete', + timestamp: Date.now(), + document: docEntry.filename, + taskIndex, + success: result.success, + summary: shortSummary, + fullResponse: fullSynopsis, + elapsedMs, + usageStats: result.usageStats, + claudeSessionId: result.claudeSessionId, + }; + + // Add history entry if enabled + if (writeHistory) { + const historyEntry: HistoryEntry = { + id: generateUUID(), + type: 'AUTO', + timestamp: Date.now(), + summary: shortSummary, + fullResponse: fullSynopsis, + claudeSessionId: result.claudeSessionId, + projectPath: session.cwd, + sessionId: session.id, + success: result.success, + usageStats: result.usageStats, + elapsedTimeMs: elapsedMs, + }; + addHistoryEntry(historyEntry); + } + + remainingTasks = newRemainingTasks; + taskIndex++; + } + + // Document complete - handle reset-on-completion + if (docEntry.resetOnCompletion && docTasksCompleted > 0) { + const { content: currentContent } = readDocAndCountTasks(folderPath, docEntry.filename); + const resetContent = uncheckAllTasks(currentContent); + writeDoc(folderPath, docEntry.filename + '.md', resetContent); + } + + // Emit document complete event + yield { + type: 'document_complete', + timestamp: Date.now(), + document: docEntry.filename, + tasksCompleted: docTasksCompleted, + }; + } + + // Check if we should continue looping + if (!playbook.loopEnabled) { + break; + } + + // Check max loop limit + if (playbook.maxLoops !== null && playbook.maxLoops !== undefined && loopIteration + 1 >= playbook.maxLoops) { + break; + } + + // Check if any non-reset documents have remaining tasks + const hasAnyNonResetDocs = playbook.documents.some(doc => !doc.resetOnCompletion); + + if (hasAnyNonResetDocs) { + let anyNonResetDocsHaveTasks = false; + for (const doc of playbook.documents) { + if (doc.resetOnCompletion) continue; + const { taskCount } = readDocAndCountTasks(folderPath, doc.filename); + if (taskCount > 0) { + anyNonResetDocsHaveTasks = true; + break; + } + } + if (!anyNonResetDocsHaveTasks) { + break; + } + } else { + // All documents are reset docs - exit after one pass + break; + } + + // Safety check + if (!anyTasksProcessedThisIteration) { + break; + } + + // Emit loop complete event + const loopElapsedMs = Date.now() - loopStartTime; + const loopUsageStats: UsageStats | undefined = + loopTotalInputTokens > 0 || loopTotalOutputTokens > 0 + ? { + inputTokens: loopTotalInputTokens, + outputTokens: loopTotalOutputTokens, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalCostUsd: loopTotalCost, + contextWindow: 200000, + } + : undefined; + + yield { + type: 'loop_complete', + timestamp: Date.now(), + iteration: loopIteration + 1, + tasksCompleted: loopTasksCompleted, + elapsedMs: loopElapsedMs, + usageStats: loopUsageStats, + }; + + // Add loop summary history entry + if (writeHistory) { + const loopSummary = `Loop ${loopIteration + 1} completed: ${loopTasksCompleted} tasks accomplished`; + const historyEntry: HistoryEntry = { + id: generateUUID(), + type: 'LOOP_SUMMARY', + timestamp: Date.now(), + summary: loopSummary, + projectPath: session.cwd, + sessionId: session.id, + success: true, + elapsedTimeMs: loopElapsedMs, + usageStats: loopUsageStats, + }; + addHistoryEntry(historyEntry); + } + + // Reset per-loop tracking + loopStartTime = Date.now(); + loopTasksCompleted = 0; + loopTotalInputTokens = 0; + loopTotalOutputTokens = 0; + loopTotalCost = 0; + + loopIteration++; + } + + // Emit complete event + yield { + type: 'complete', + timestamp: Date.now(), + success: true, + totalTasksCompleted: totalCompletedTasks, + totalElapsedMs: Date.now() - batchStartTime, + totalCost, + }; +} diff --git a/src/cli/services/playbooks.ts b/src/cli/services/playbooks.ts new file mode 100644 index 00000000..b2630e07 --- /dev/null +++ b/src/cli/services/playbooks.ts @@ -0,0 +1,88 @@ +// Playbooks service for CLI +// Reads playbook files from the Maestro config directory + +import * as fs from 'fs'; +import * as path from 'path'; +import { getConfigDirectory } from './storage'; +import type { Playbook } from '../../shared/types'; + +// Playbook file structure +interface PlaybooksFile { + playbooks: Playbook[]; +} + +/** + * Get the playbooks directory path + */ +function getPlaybooksDir(): string { + return path.join(getConfigDirectory(), 'playbooks'); +} + +/** + * Get the playbooks file path for a session + */ +function getPlaybooksFilePath(sessionId: string): string { + return path.join(getPlaybooksDir(), `${sessionId}.json`); +} + +/** + * Read playbooks for a session + * Returns empty array if no playbooks file exists + */ +export function readPlaybooks(sessionId: string): Playbook[] { + const filePath = getPlaybooksFilePath(sessionId); + + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content) as PlaybooksFile; + return Array.isArray(data.playbooks) ? data.playbooks : []; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } +} + +/** + * Get a specific playbook by ID + */ +export function getPlaybook(sessionId: string, playbookId: string): Playbook | undefined { + const playbooks = readPlaybooks(sessionId); + return playbooks.find(p => p.id === playbookId); +} + +/** + * List all playbooks across all sessions + * Returns playbooks with their session IDs + */ +export function listAllPlaybooks(): Array { + const playbooksDir = getPlaybooksDir(); + const result: Array = []; + + try { + if (!fs.existsSync(playbooksDir)) { + return result; + } + + const files = fs.readdirSync(playbooksDir); + + for (const file of files) { + if (!file.endsWith('.json')) continue; + + const sessionId = file.replace('.json', ''); + const playbooks = readPlaybooks(sessionId); + + for (const playbook of playbooks) { + result.push({ ...playbook, sessionId }); + } + } + + return result; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return result; + } + throw error; + } +} diff --git a/src/cli/services/storage.ts b/src/cli/services/storage.ts new file mode 100644 index 00000000..10d4c44b --- /dev/null +++ b/src/cli/services/storage.ts @@ -0,0 +1,136 @@ +// Storage service for CLI +// Reads Electron Store JSON files directly from disk + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { Group, SessionInfo, HistoryEntry } from '../../shared/types'; + +// Get the Maestro config directory path +function getConfigDir(): string { + const platform = os.platform(); + const home = os.homedir(); + + if (platform === 'darwin') { + return path.join(home, 'Library', 'Application Support', 'Maestro'); + } else if (platform === 'win32') { + return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Maestro'); + } else { + // Linux and others + return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Maestro'); + } +} + +/** + * Read and parse an Electron Store JSON file + * Returns undefined if file doesn't exist + */ +function readStoreFile(filename: string): T | undefined { + const filePath = path.join(getConfigDir(), filename); + + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(content) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw error; + } +} + +// Store file structures (as used by Electron Store) +interface SessionsStore { + sessions: SessionInfo[]; +} + +interface GroupsStore { + groups: Group[]; +} + +interface HistoryStore { + entries: HistoryEntry[]; +} + +interface SettingsStore { + activeThemeId?: string; + [key: string]: unknown; +} + +/** + * Read all sessions from storage + */ +export function readSessions(): SessionInfo[] { + const data = readStoreFile('maestro-sessions.json'); + return data?.sessions || []; +} + +/** + * Read all groups from storage + */ +export function readGroups(): Group[] { + const data = readStoreFile('maestro-groups.json'); + return data?.groups || []; +} + +/** + * Read history entries from storage + * Optionally filter by project path or session ID + */ +export function readHistory(projectPath?: string, sessionId?: string): HistoryEntry[] { + const data = readStoreFile('maestro-history.json'); + let entries = data?.entries || []; + + if (projectPath) { + entries = entries.filter(e => e.projectPath === projectPath); + } + + if (sessionId) { + entries = entries.filter(e => e.sessionId === sessionId); + } + + return entries; +} + +/** + * Read settings from storage + */ +export function readSettings(): SettingsStore { + const data = readStoreFile('maestro-settings.json'); + return data || {}; +} + +/** + * Get a session by ID + */ +export function getSessionById(sessionId: string): SessionInfo | undefined { + const sessions = readSessions(); + return sessions.find(s => s.id === sessionId); +} + +/** + * Get sessions by group ID + */ +export function getSessionsByGroup(groupId: string): SessionInfo[] { + const sessions = readSessions(); + return sessions.filter(s => s.groupId === groupId); +} + +/** + * Get the config directory path (exported for playbooks service) + */ +export function getConfigDirectory(): string { + return getConfigDir(); +} + +/** + * Add a history entry + */ +export function addHistoryEntry(entry: HistoryEntry): void { + const filePath = path.join(getConfigDir(), 'maestro-history.json'); + const data = readStoreFile('maestro-history.json') || { entries: [] }; + + data.entries.push(entry); + + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); +} diff --git a/src/main/index.ts b/src/main/index.ts index 051802a9..3730ce89 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -908,6 +908,7 @@ function setupIpcHandlers() { cwd: p.cwd, isTerminal: p.isTerminal, isBatchMode: p.isBatchMode || false, + startTime: p.startTime, })); }); diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index e56ab905..6551065c 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -29,6 +29,7 @@ interface ManagedProcess { lastCommand?: string; // Last command sent to terminal (for filtering command echoes) sessionIdEmitted?: boolean; // True after session_id has been emitted (prevents duplicate emissions) resultEmitted?: boolean; // True after result data has been emitted (prevents duplicate emissions) + startTime: number; // Timestamp when process was spawned } /** @@ -191,6 +192,7 @@ export class ProcessManager extends EventEmitter { cwd, pid: ptyProcess.pid, isTerminal: true, + startTime: Date.now(), }; this.processes.set(sessionId, managedProcess); @@ -280,6 +282,7 @@ export class ProcessManager extends EventEmitter { isBatchMode, isStreamJsonMode, jsonBuffer: isBatchMode ? '' : undefined, + startTime: Date.now(), }; this.processes.set(sessionId, managedProcess); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 960134b8..7f31fb22 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -5413,7 +5413,8 @@ export default function MaestroConsole() { // Refresh file tree for a session and return the changes detected const refreshFileTree = useCallback(async (sessionId: string): Promise => { - const session = sessions.find(s => s.id === sessionId); + // Use sessionsRef to avoid dependency on sessions state (prevents timer reset on every session change) + const session = sessionsRef.current.find(s => s.id === sessionId); if (!session) return undefined; try { @@ -5438,7 +5439,7 @@ export default function MaestroConsole() { )); return undefined; } - }, [sessions]); + }, []); // Refresh both file tree and git state for a session const refreshGitFileState = useCallback(async (sessionId: string) => { diff --git a/src/renderer/components/AutoRunDocumentSelector.tsx b/src/renderer/components/AutoRunDocumentSelector.tsx index 762bf2d4..530f7fdd 100644 --- a/src/renderer/components/AutoRunDocumentSelector.tsx +++ b/src/renderer/components/AutoRunDocumentSelector.tsx @@ -210,7 +210,7 @@ export function AutoRunDocumentSelector({ backgroundColor: node.path === selectedDocument ? theme.colors.bgActivity : 'transparent', }} > - {node.name} + {node.name}.md ); }; @@ -231,7 +231,7 @@ export function AutoRunDocumentSelector({ }} > - {selectedDocument || 'Select a document...'} + {selectedDocument ? `${selectedDocument}.md` : 'Select a document...'} - {doc} + {doc}.md )) )} diff --git a/src/renderer/components/BatchRunnerModal.tsx b/src/renderer/components/BatchRunnerModal.tsx index ee5970f6..57863b4b 100644 --- a/src/renderer/components/BatchRunnerModal.tsx +++ b/src/renderer/components/BatchRunnerModal.tsx @@ -1175,7 +1175,7 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { className={`flex-1 text-sm font-medium truncate ${doc.isMissing ? 'line-through' : ''}`} style={{ color: doc.isMissing ? theme.colors.error : theme.colors.textMain }} > - {doc.filename} + {doc.filename}.md {/* Missing Indicator */} @@ -1902,7 +1902,7 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { className="flex-1 text-sm text-left truncate" style={{ color: theme.colors.textMain }} > - {filename} + {filename}.md {/* Task Count */} diff --git a/src/renderer/components/ExecutionQueueIndicator.tsx b/src/renderer/components/ExecutionQueueIndicator.tsx index b9b28b84..5d1ab2eb 100644 --- a/src/renderer/components/ExecutionQueueIndicator.tsx +++ b/src/renderer/components/ExecutionQueueIndicator.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef, useState, useEffect, useCallback } from 'react'; import { ListOrdered, Command, MessageSquare } from 'lucide-react'; import type { Session, Theme, QueuedItem } from '../types'; @@ -15,10 +15,9 @@ interface ExecutionQueueIndicatorProps { */ export function ExecutionQueueIndicator({ session, theme, onClick }: ExecutionQueueIndicatorProps) { const queue = session.executionQueue || []; - - if (queue.length === 0) { - return null; - } + const containerRef = useRef(null); + const pillsContainerRef = useRef(null); + const [maxVisiblePills, setMaxVisiblePills] = useState(2); // Count items by type const messageCount = queue.filter(item => item.type === 'message').length; @@ -33,8 +32,67 @@ export function ExecutionQueueIndicator({ session, theme, onClick }: ExecutionQu const tabNames = Object.keys(tabCounts); + // Calculate how many pills we can show based on available space + const calculateMaxPills = useCallback(() => { + if (!containerRef.current || !pillsContainerRef.current) return; + + const container = containerRef.current; + const pillsContainer = pillsContainerRef.current; + + // Get total container width + const containerWidth = container.clientWidth; + + // Calculate space used by other elements (left side + type counts + "Click to view") + // We need to measure everything except the pills container + const containerStyle = getComputedStyle(container); + const containerPadding = parseFloat(containerStyle.paddingLeft) + parseFloat(containerStyle.paddingRight); + const containerGap = 8; // gap-2 = 0.5rem = 8px + + // Measure left side elements (icon + "X items queued" text) + const leftElements = container.querySelectorAll(':scope > *:not([data-pills-container])'); + let usedWidth = containerPadding; + + leftElements.forEach((el) => { + if (el !== pillsContainer) { + const rect = el.getBoundingClientRect(); + usedWidth += rect.width + containerGap; + } + }); + + // Available space for pills (leave some buffer for the +N indicator and spacing) + const availableWidth = containerWidth - usedWidth - 80; // 80px buffer for +N and spacing + + // Estimate pill width (approximately 80-100px per pill depending on content) + const avgPillWidth = 90; + const pillGap = 4; // gap-1 = 0.25rem = 4px + + const maxPills = Math.min(5, Math.max(1, Math.floor(availableWidth / (avgPillWidth + pillGap)))); + setMaxVisiblePills(maxPills); + }, []); + + // Use ResizeObserver to recalculate when container size changes + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver(() => { + calculateMaxPills(); + }); + + observer.observe(containerRef.current); + + // Initial calculation + calculateMaxPills(); + + return () => observer.disconnect(); + }, [calculateMaxPills, queue.length, tabNames.length]); + + if (queue.length === 0) { + return null; + } + return ( ); } diff --git a/src/renderer/components/ProcessMonitor.tsx b/src/renderer/components/ProcessMonitor.tsx index 380653d8..29706a65 100644 --- a/src/renderer/components/ProcessMonitor.tsx +++ b/src/renderer/components/ProcessMonitor.tsx @@ -19,6 +19,7 @@ interface ActiveProcess { cwd: string; isTerminal: boolean; isBatchMode: boolean; + startTime: number; } interface ProcessNode { @@ -37,6 +38,30 @@ interface ProcessNode { cwd?: string; claudeSessionId?: string; // UUID octet from the Claude session (for AI processes) tabId?: string; // Tab ID for navigation to specific AI tab + startTime?: number; // Process start timestamp for runtime calculation +} + +// Format runtime in human readable format (e.g., "2m 30s", "1h 5m", "3d 2h") +function formatRuntime(startTime: number): string { + const elapsed = Date.now() - startTime; + const seconds = Math.floor(elapsed / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainingHours = hours % 24; + return `${days}d ${remainingHours}h`; + } + if (hours > 0) { + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; + } + if (minutes > 0) { + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; + } + return `${seconds}s`; } export function ProcessMonitor(props: ProcessMonitorProps) { @@ -310,7 +335,8 @@ export function ProcessMonitor(props: ProcessMonitorProps) { toolType: proc.toolType, cwd: proc.cwd, claudeSessionId, - tabId + tabId, + startTime: proc.startTime }); }); @@ -627,6 +653,11 @@ export function ProcessMonitor(props: ProcessMonitorProps) { PID: {node.pid} + {node.startTime && ( + + {formatRuntime(node.startTime)} + + )} (function className="text-xs font-medium shrink-0" style={{ color: theme.colors.textMain }} > - Document {batchRunState.currentDocumentIndex + 1}/{batchRunState.documents.length}: {batchRunState.documents[batchRunState.currentDocumentIndex]} + Document {batchRunState.currentDocumentIndex + 1}/{batchRunState.documents.length}: {batchRunState.documents[batchRunState.currentDocumentIndex]}.md