feat: Add CLI tool and Process Monitor improvements

CLI Tool (maestro-playbook):
- New standalone binary for running playbooks from command line
- Supports list groups, list agents, list playbooks, run playbook
- JSONL output format for easy parsing and piping
- Dry-run mode and no-history options
- Documentation added to README.md

Process Monitor Enhancements:
- Show only running processes (hide idle/inactive)
- Display human-readable runtime (e.g., "2m 30s", "1h 5m")
- Show Claude session ID with click-to-navigate
- Improved column layout with less spacing
- Default to expanded view
- Visual feedback on refresh (500ms spinner)
- Kill process functionality with confirmation

Process Tracking:
- Added startTime to ManagedProcess interface
- Expose startTime via getActiveProcesses IPC

Claude ID: c7e537b9-c2a7-4cc7-b49b-e7ea0ca1d4cb
Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35
This commit is contained in:
Pedram Amini
2025-12-04 07:52:43 -06:00
parent b223bfaa7c
commit 42fbf067ad
23 changed files with 2297 additions and 37 deletions

View File

@@ -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 <group-id>
# List playbooks for a session
maestro-playbook list playbooks --session <session-id>
# Run a playbook (streams JSONL to stdout)
maestro-playbook run --session <session-id> --playbook <playbook-id>
# Dry run (shows what would be executed)
maestro-playbook run --session <session-id> --playbook <playbook-id> --dry-run
# Run without writing to history
maestro-playbook run --session <session-id> --playbook <playbook-id> --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 <session> -p <playbook>
{"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 <session-id> -p <playbook-id> >> /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:

634
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<void> {
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);
}
}

48
src/cli/index.ts Normal file
View File

@@ -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 <id>', 'Filter by group ID')
.action(listAgents);
list
.command('playbooks')
.description('List playbooks for a session')
.requiredOption('-s, --session <id>', 'Session ID')
.action(listPlaybooks);
// Run command
program
.command('run')
.description('Run a playbook')
.requiredOption('-s, --session <id>', 'Session ID')
.requiredOption('-p, --playbook <id>', 'Playbook ID')
.option('--dry-run', 'Show what would be executed without running')
.option('--no-history', 'Do not write history entries')
.action(runPlaybook);
program.parse();

252
src/cli/output/jsonl.ts Normal file
View File

@@ -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 });
}

View File

@@ -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<AgentResult> {
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<string, number>[]) {
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');
}

View File

@@ -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<JsonlEvent> {
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,
};
}

View File

@@ -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<Playbook & { sessionId: string }> {
const playbooksDir = getPlaybooksDir();
const result: Array<Playbook & { sessionId: string }> = [];
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;
}
}

136
src/cli/services/storage.ts Normal file
View File

@@ -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<T>(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<SessionsStore>('maestro-sessions.json');
return data?.sessions || [];
}
/**
* Read all groups from storage
*/
export function readGroups(): Group[] {
const data = readStoreFile<GroupsStore>('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<HistoryStore>('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<SettingsStore>('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<HistoryStore>('maestro-history.json') || { entries: [] };
data.entries.push(entry);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
}

View File

@@ -908,6 +908,7 @@ function setupIpcHandlers() {
cwd: p.cwd,
isTerminal: p.isTerminal,
isBatchMode: p.isBatchMode || false,
startTime: p.startTime,
}));
});

View File

@@ -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);

View File

@@ -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<FileTreeChanges | undefined> => {
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) => {

View File

@@ -210,7 +210,7 @@ export function AutoRunDocumentSelector({
backgroundColor: node.path === selectedDocument ? theme.colors.bgActivity : 'transparent',
}}
>
{node.name}
{node.name}.md
</button>
);
};
@@ -231,7 +231,7 @@ export function AutoRunDocumentSelector({
}}
>
<span className="truncate">
{selectedDocument || 'Select a document...'}
{selectedDocument ? `${selectedDocument}.md` : 'Select a document...'}
</span>
<ChevronDown
className={`w-4 h-4 ml-2 shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
@@ -274,7 +274,7 @@ export function AutoRunDocumentSelector({
backgroundColor: doc === selectedDocument ? theme.colors.bgActivity : 'transparent',
}}
>
{doc}
{doc}.md
</button>
))
)}

View File

@@ -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
</span>
{/* 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
</span>
{/* Task Count */}

View File

@@ -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<HTMLButtonElement>(null);
const pillsContainerRef = useRef<HTMLDivElement>(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 (
<button
ref={containerRef}
onClick={onClick}
className="w-full mb-2 px-3 py-2 rounded-lg border flex items-center gap-2 text-sm transition-all hover:opacity-90"
style={{
@@ -45,14 +103,14 @@ export function ExecutionQueueIndicator({ session, theme, onClick }: ExecutionQu
>
<ListOrdered className="w-4 h-4 flex-shrink-0" style={{ color: theme.colors.warning }} />
<span className="flex-1 text-left">
<span className="text-left whitespace-nowrap">
<span className="font-semibold">{queue.length}</span>
{' '}
{queue.length === 1 ? 'item' : 'items'} queued
</span>
{/* Item type breakdown */}
<div className="flex items-center gap-2 text-xs opacity-70">
<div className="flex items-center gap-2 text-xs opacity-70 flex-shrink-0">
{messageCount > 0 && (
<span className="flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
@@ -67,12 +125,15 @@ export function ExecutionQueueIndicator({ session, theme, onClick }: ExecutionQu
)}
</div>
{/* Tab pills - show first 2 tabs, then +N more */}
<div className="flex items-center gap-1">
{tabNames.slice(0, 2).map(tabName => (
{/* Spacer to push pills to the right */}
<div className="flex-1" />
{/* Tab pills - dynamically show as many as fit, then +N more */}
<div ref={pillsContainerRef} data-pills-container className="flex items-center gap-1 flex-shrink-0">
{tabNames.slice(0, maxVisiblePills).map(tabName => (
<span
key={tabName}
className="px-1.5 py-0.5 rounded text-xs font-mono"
className="px-1.5 py-0.5 rounded text-xs font-mono whitespace-nowrap"
style={{
backgroundColor: theme.colors.accent + '30',
color: theme.colors.textMain
@@ -82,17 +143,17 @@ export function ExecutionQueueIndicator({ session, theme, onClick }: ExecutionQu
{tabCounts[tabName] > 1 && ` (${tabCounts[tabName]})`}
</span>
))}
{tabNames.length > 2 && (
{tabNames.length > maxVisiblePills && (
<span
className="px-1.5 py-0.5 rounded text-xs"
className="px-1.5 py-0.5 rounded text-xs whitespace-nowrap"
style={{ color: theme.colors.textDim }}
>
+{tabNames.length - 2}
+{tabNames.length - maxVisiblePills}
</span>
)}
</div>
<span className="text-xs opacity-50">Click to view</span>
<span className="text-xs opacity-50 flex-shrink-0 whitespace-nowrap">Click to view</span>
</button>
);
}

View File

@@ -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) {
<span className="text-xs font-mono flex-shrink-0" style={{ color: theme.colors.textDim }}>
PID: {node.pid}
</span>
{node.startTime && (
<span className="text-xs font-mono flex-shrink-0" style={{ color: theme.colors.textDim }}>
{formatRuntime(node.startTime)}
</span>
)}
<span
className="text-xs px-2 py-0.5 rounded"
style={{

View File

@@ -309,7 +309,7 @@ export const RightPanel = forwardRef<RightPanelHandle, RightPanelProps>(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
</span>
<div
className="flex-1 h-1 rounded-full overflow-hidden"

116
src/shared/types.ts Normal file
View File

@@ -0,0 +1,116 @@
// Shared type definitions for Maestro CLI and Electron app
// These types are used by both the CLI tool and the renderer process
export type ToolType = 'claude' | 'claude-code' | 'aider' | 'opencode' | 'terminal';
// Session group
export interface Group {
id: string;
name: string;
emoji: string;
collapsed: boolean;
}
// Simplified session interface for CLI (subset of full Session)
export interface SessionInfo {
id: string;
groupId?: string;
name: string;
toolType: ToolType;
cwd: string;
projectRoot: string;
autoRunFolderPath?: string;
}
// Usage statistics from Claude Code CLI
export interface UsageStats {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
totalCostUsd: number;
contextWindow: number;
}
// History entry types for the History panel
export type HistoryEntryType = 'AUTO' | 'USER' | 'LOOP_SUMMARY';
export interface HistoryEntry {
id: string;
type: HistoryEntryType;
timestamp: number;
summary: string;
fullResponse?: string;
claudeSessionId?: string;
sessionName?: string;
projectPath: string;
sessionId?: string;
contextUsage?: number;
usageStats?: UsageStats;
success?: boolean;
elapsedTimeMs?: number;
validated?: boolean;
}
// Document entry within a playbook
export interface PlaybookDocumentEntry {
filename: string;
resetOnCompletion: boolean;
}
// A saved Playbook configuration
export interface Playbook {
id: string;
name: string;
createdAt: number;
updatedAt: number;
documents: PlaybookDocumentEntry[];
loopEnabled: boolean;
maxLoops?: number | null;
prompt: string;
worktreeSettings?: {
branchNameTemplate: string;
createPROnCompletion: boolean;
prTargetBranch?: string;
};
}
// Document entry in the batch run queue (runtime version with IDs)
export interface BatchDocumentEntry {
id: string;
filename: string;
resetOnCompletion: boolean;
isDuplicate: boolean;
isMissing?: boolean;
}
// Git worktree configuration for Auto Run
export interface WorktreeConfig {
enabled: boolean;
path: string;
branchName: string;
createPROnCompletion: boolean;
prTargetBranch: string;
}
// Configuration for starting a batch run
export interface BatchRunConfig {
documents: BatchDocumentEntry[];
prompt: string;
loopEnabled: boolean;
maxLoops?: number | null;
worktree?: WorktreeConfig;
}
// Agent configuration
export interface AgentConfig {
id: string;
name: string;
binaryName: string;
command: string;
args: string[];
available: boolean;
path?: string;
requiresPty?: boolean;
hidden?: boolean;
}

21
tsconfig.cli.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "dist/cli",
"rootDir": "src",
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/cli/**/*", "src/shared/**/*"],
"exclude": ["node_modules", "dist", "release"]
}