feat: implement Claudeman - Claude session manager with Ralph Loop

Claudeman manages multiple Claude CLI sessions as subprocesses with:
- Session management (start/stop/list/logs)
- Priority-based task queue with dependency support
- Ralph Loop for autonomous task assignment and completion detection
- Time-aware loops for extended work sessions
- JSON file persistence to ~/.claudeman/state.json

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-18 04:50:46 +01:00
co-authored by Claude Opus 4.5
commit e1bb800473
16 changed files with 2830 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"permissions": {
"defaultMode": "bypassPermissions"
}
}
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store
+65
View File
@@ -0,0 +1,65 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What This Repository Is
This is a **Claude Code configuration template** containing reusable CLAUDE.md and settings files. Copy these to new projects to bootstrap Claude Code configuration.
### Files to Copy
- `CLAUDE.md` → project root (then customize the Project Overview section)
- `.claude/settings.json` → `.claude/settings.json`
---
## Project Overview
<!-- Customize this section when using in a real project -->
- **Project Name**: claudeman (Claude Code config template)
- **Description**: Reusable Claude Code configuration files
- **Last Updated**: 2026-01-18
---
## Work Principles
### Autonomy
Full permissions granted via `.claude/settings.json`. Act decisively - read, write, edit, execute freely.
### Git Discipline
- Commit after every meaningful change - never batch unrelated work
- Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`
- Commit message = what changed + why (not how)
### Planning Mode
**Automatically enter planning mode** when:
- Multi-file changes (3+ files)
- Architectural decisions
- New feature implementation
- Refactoring existing functionality
**Skip planning mode** for: single-file fixes, typo corrections, simple config changes.
---
## Ralph Loop (Autonomous Work)
Start with `/ralph-loop`, cancel with `/cancel-ralph`.
Ralph loops enable persistent autonomous work. When active, continue iterating until completion criteria are met.
### Core Behaviors
1. Work incrementally - one sub-task at a time
2. Commit after each completion
3. Self-correct: implement → test → fix failures → lint → fix errors → commit
4. Only output completion phrase when ALL requirements done AND tests pass
### Time-Aware Loops
When given a minimum duration, track elapsed time and self-generate additional tasks if primary work completes early. Only output completion phrase when both tasks AND time requirements are met.
---
## Session Log
| Date | Tasks Completed | Files Changed | Notes |
|------|-----------------|---------------|-------|
| 2026-01-18 | Improve CLAUDE.md template | CLAUDE.md | Made more concise |
+203
View File
@@ -0,0 +1,203 @@
# Claudeman
A Claude Code session manager with an autonomous Ralph Loop for task assignment and monitoring.
## Features
- **Session Management**: Spawn and manage multiple Claude CLI sessions as subprocesses
- **Task Queue**: Priority-based task queue with dependency support
- **Ralph Loop**: Autonomous control loop that assigns tasks to idle sessions and monitors completion
- **Time-Aware Loops**: Set minimum durations for extended autonomous work sessions
- **State Persistence**: All state persisted to `~/.claudeman/state.json`
## Installation
```bash
npm install
npm run build
npm link # Optional: make 'claudeman' available globally
```
## Quick Start
```bash
# Start a Claude session
claudeman start --dir /path/to/project
# Add tasks to the queue
claudeman task add "Fix the bug in auth.ts"
claudeman task add "Add tests for the API" --priority 5
# Start the Ralph loop to process tasks
claudeman ralph start
# Check status
claudeman status
```
## Commands
### Session Management
```bash
# Start a new session
claudeman session start [--dir <path>]
claudeman start [--dir <path>] # shorthand
# Stop a session
claudeman session stop <session-id>
# List sessions
claudeman session list
claudeman list # shorthand
# View session output
claudeman session logs <session-id>
claudeman session logs <session-id> --errors # stderr
```
### Task Management
```bash
# Add a task
claudeman task add "<prompt>" [options]
--dir <path> Working directory
--priority <n> Priority (higher = processed first)
--completion <phrase> Custom completion phrase to detect
--timeout <ms> Task timeout in milliseconds
# List tasks
claudeman task list
claudeman task list --status pending
# View task details
claudeman task status <task-id>
# Remove a task
claudeman task remove <task-id>
# Clear tasks
claudeman task clear # completed tasks
claudeman task clear --failed # failed tasks
claudeman task clear --all # all tasks
```
### Ralph Loop
```bash
# Start the autonomous loop
claudeman ralph start
claudeman ralph start --min-hours 4 # run for at least 4 hours
claudeman ralph start --no-auto-generate # disable auto task generation
# Stop the loop
claudeman ralph stop
# Check loop status
claudeman ralph status
```
### Utility
```bash
# Overall status
claudeman status
# Reset all state (stops sessions, clears tasks)
claudeman reset --force
```
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Claudeman CLI │
├─────────────────────────────────────────────────────────┤
│ Ralph Loop Controller │
│ - Monitors all sessions │
│ - Assigns tasks from queue │
│ - Detects completion/failure │
│ - Self-generates follow-up tasks │
├─────────────────────────────────────────────────────────┤
│ Session Manager │ Task Queue │
│ - Spawn claude processes │ - Priority queue │
│ - Track stdin/stdout/stderr │ - Task definitions │
│ - Health monitoring │ - Dependencies │
│ - Graceful shutdown │ - Status tracking │
├─────────────────────────────────────────────────────────┤
│ State Store (JSON file persistence) │
│ - Sessions, tasks, logs │
└─────────────────────────────────────────────────────────┘
```
## Completion Detection
The Ralph Loop detects task completion by looking for:
1. **Promise tags**: `<promise>COMPLETE</promise>` or custom phrases
2. **Common indicators**: "Task completed successfully", "All tasks done", "✓ Complete"
When creating tasks, you can specify a custom completion phrase:
```bash
claudeman task add "Refactor the auth module" --completion "AUTH_REFACTOR_DONE"
```
The session output will be scanned for `<promise>AUTH_REFACTOR_DONE</promise>`.
## Time-Aware Loops
For extended autonomous work sessions:
```bash
claudeman ralph start --min-hours 8
```
When the minimum duration hasn't been reached and all tasks are complete, the Ralph Loop will auto-generate follow-up tasks like:
- Review and optimize recently changed code
- Add tests for uncovered code paths
- Update documentation
- Check for security vulnerabilities
- Run linting and fix issues
## State File
All state is persisted to `~/.claudeman/state.json`:
```json
{
"sessions": { ... },
"tasks": { ... },
"ralphLoop": {
"status": "running",
"startedAt": 1234567890,
"minDurationMs": 14400000,
"tasksCompleted": 5,
"tasksGenerated": 2
},
"config": {
"pollIntervalMs": 1000,
"defaultTimeoutMs": 300000,
"maxConcurrentSessions": 5
}
}
```
## Development
```bash
# Run in development mode
npm run dev -- start
# Build
npm run build
# Clean build artifacts
npm run clean
```
## Requirements
- Node.js 18+
- Claude CLI (`claude`) installed and available in PATH
+644
View File
@@ -0,0 +1,644 @@
{
"name": "claudeman",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claudeman",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"commander": "^12.1.0",
"uuid": "^10.0.0"
},
"bin": {
"claudeman": "dist/index.js"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.15.0",
"typescript": "^5.5.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "20.19.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.2",
"@esbuild/android-arm": "0.27.2",
"@esbuild/android-arm64": "0.27.2",
"@esbuild/android-x64": "0.27.2",
"@esbuild/darwin-arm64": "0.27.2",
"@esbuild/darwin-x64": "0.27.2",
"@esbuild/freebsd-arm64": "0.27.2",
"@esbuild/freebsd-x64": "0.27.2",
"@esbuild/linux-arm": "0.27.2",
"@esbuild/linux-arm64": "0.27.2",
"@esbuild/linux-ia32": "0.27.2",
"@esbuild/linux-loong64": "0.27.2",
"@esbuild/linux-mips64el": "0.27.2",
"@esbuild/linux-ppc64": "0.27.2",
"@esbuild/linux-riscv64": "0.27.2",
"@esbuild/linux-s390x": "0.27.2",
"@esbuild/linux-x64": "0.27.2",
"@esbuild/netbsd-arm64": "0.27.2",
"@esbuild/netbsd-x64": "0.27.2",
"@esbuild/openbsd-arm64": "0.27.2",
"@esbuild/openbsd-x64": "0.27.2",
"@esbuild/openharmony-arm64": "0.27.2",
"@esbuild/sunos-x64": "0.27.2",
"@esbuild/win32-arm64": "0.27.2",
"@esbuild/win32-ia32": "0.27.2",
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "claudeman",
"version": "1.0.0",
"description": "Claude Code session manager with autonomous Ralph Loop",
"type": "module",
"main": "dist/index.js",
"bin": {
"claudeman": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"clean": "rm -rf dist"
},
"keywords": [
"claude",
"cli",
"session-manager",
"automation"
],
"author": "",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"commander": "^12.1.0",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.15.0",
"typescript": "^5.5.0"
},
"engines": {
"node": ">=18.0.0"
}
}
+452
View File
@@ -0,0 +1,452 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js';
import { getStore } from './state-store.js';
const program = new Command();
program
.name('claudeman')
.description('Claude Code session manager with autonomous Ralph Loop')
.version('1.0.0');
// ============ Session Commands ============
const sessionCmd = program
.command('session')
.alias('s')
.description('Manage Claude sessions');
sessionCmd
.command('start')
.description('Start a new Claude session')
.option('-d, --dir <path>', 'Working directory', process.cwd())
.action(async (options) => {
try {
const manager = getSessionManager();
const session = await manager.createSession(options.dir);
console.log(chalk.green(`✓ Session started: ${session.id}`));
console.log(` Working directory: ${session.workingDir}`);
console.log(` PID: ${session.pid}`);
} catch (err) {
console.error(chalk.red(`✗ Failed to start session: ${(err as Error).message}`));
process.exit(1);
}
});
sessionCmd
.command('stop <id>')
.description('Stop a session')
.action(async (id) => {
try {
const manager = getSessionManager();
await manager.stopSession(id);
console.log(chalk.green(`✓ Session stopped: ${id}`));
} catch (err) {
console.error(chalk.red(`✗ Failed to stop session: ${(err as Error).message}`));
process.exit(1);
}
});
sessionCmd
.command('list')
.alias('ls')
.description('List all sessions')
.action(() => {
const manager = getSessionManager();
const sessions = manager.getAllSessions();
const stored = manager.getStoredSessions();
if (sessions.length === 0 && Object.keys(stored).length === 0) {
console.log(chalk.yellow('No sessions found'));
return;
}
console.log(chalk.bold('\nActive Sessions:'));
if (sessions.length === 0) {
console.log(' (none)');
} else {
for (const session of sessions) {
const status = session.status === 'idle'
? chalk.green('idle')
: session.status === 'busy'
? chalk.yellow('busy')
: chalk.red(session.status);
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
}
}
const stoppedSessions = Object.values(stored).filter((s) => s.status === 'stopped');
if (stoppedSessions.length > 0) {
console.log(chalk.bold('\nStopped Sessions:'));
for (const session of stoppedSessions) {
console.log(` ${chalk.gray(session.id.slice(0, 8))} ${chalk.gray('stopped')} ${session.workingDir}`);
}
}
console.log('');
});
sessionCmd
.command('logs <id>')
.description('View session output')
.option('-e, --errors', 'Show stderr instead of stdout')
.action((id, options) => {
const manager = getSessionManager();
const output = options.errors
? manager.getSessionError(id)
: manager.getSessionOutput(id);
if (output === null) {
console.log(chalk.yellow(`Session ${id} not found or not active`));
return;
}
if (output === '') {
console.log(chalk.gray('(no output)'));
return;
}
console.log(output);
});
// ============ Task Commands ============
const taskCmd = program
.command('task')
.alias('t')
.description('Manage tasks');
taskCmd
.command('add <prompt>')
.description('Add a new task')
.option('-d, --dir <path>', 'Working directory', process.cwd())
.option('-p, --priority <n>', 'Priority (higher = first)', '0')
.option('-c, --completion <phrase>', 'Completion phrase to detect')
.option('--timeout <ms>', 'Timeout in milliseconds')
.action((prompt, options) => {
const queue = getTaskQueue();
const task = queue.addTask({
prompt,
workingDir: options.dir,
priority: parseInt(options.priority, 10),
completionPhrase: options.completion,
timeoutMs: options.timeout ? parseInt(options.timeout, 10) : undefined,
});
console.log(chalk.green(`✓ Task added: ${task.id}`));
console.log(` Prompt: ${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}`);
console.log(` Priority: ${task.priority}`);
});
taskCmd
.command('list')
.alias('ls')
.description('List all tasks')
.option('-s, --status <status>', 'Filter by status (pending, running, completed, failed)')
.action((options) => {
const queue = getTaskQueue();
let tasks = queue.getAllTasks();
if (options.status) {
tasks = tasks.filter((t) => t.status === options.status);
}
if (tasks.length === 0) {
console.log(chalk.yellow('No tasks found'));
return;
}
const statusColors = {
pending: chalk.gray,
running: chalk.yellow,
completed: chalk.green,
failed: chalk.red,
};
console.log(chalk.bold('\nTasks:'));
for (const task of tasks) {
const color = statusColors[task.status];
const prompt = task.prompt.slice(0, 40) + (task.prompt.length > 40 ? '...' : '');
console.log(` ${chalk.cyan(task.id.slice(0, 8))} ${color(task.status.padEnd(10))} [${task.priority}] ${prompt}`);
}
const counts = queue.getCount();
console.log(chalk.bold('\nSummary:'));
console.log(` Pending: ${counts.pending}, Running: ${counts.running}, Completed: ${counts.completed}, Failed: ${counts.failed}`);
console.log('');
});
taskCmd
.command('status <id>')
.description('Show task details')
.action((id) => {
const queue = getTaskQueue();
const task = queue.getTask(id);
if (!task) {
console.log(chalk.red(`Task ${id} not found`));
return;
}
console.log(chalk.bold('\nTask Details:'));
console.log(` ID: ${task.id}`);
console.log(` Status: ${task.status}`);
console.log(` Priority: ${task.priority}`);
console.log(` Prompt: ${task.prompt}`);
console.log(` Working Dir: ${task.workingDir}`);
if (task.assignedSessionId) {
console.log(` Session: ${task.assignedSessionId}`);
}
if (task.error) {
console.log(` Error: ${chalk.red(task.error)}`);
}
if (task.output) {
console.log(chalk.bold('\nOutput:'));
console.log(task.output.slice(0, 500) + (task.output.length > 500 ? '...' : ''));
}
console.log('');
});
taskCmd
.command('remove <id>')
.alias('rm')
.description('Remove a task')
.action((id) => {
const queue = getTaskQueue();
if (queue.removeTask(id)) {
console.log(chalk.green(`✓ Task removed: ${id}`));
} else {
console.log(chalk.red(`Task ${id} not found`));
}
});
taskCmd
.command('clear')
.description('Clear completed/failed tasks')
.option('-a, --all', 'Clear all tasks')
.option('-f, --failed', 'Clear only failed tasks')
.action((options) => {
const queue = getTaskQueue();
let count: number;
if (options.all) {
count = queue.clearAll();
console.log(chalk.green(`✓ Cleared ${count} tasks`));
} else if (options.failed) {
count = queue.clearFailed();
console.log(chalk.green(`✓ Cleared ${count} failed tasks`));
} else {
count = queue.clearCompleted();
console.log(chalk.green(`✓ Cleared ${count} completed tasks`));
}
});
// ============ Ralph Loop Commands ============
const ralphCmd = program
.command('ralph')
.alias('r')
.description('Control the Ralph autonomous loop');
ralphCmd
.command('start')
.description('Start the Ralph loop')
.option('-m, --min-hours <hours>', 'Minimum duration in hours')
.option('--no-auto-generate', 'Disable auto-generating follow-up tasks')
.action(async (options) => {
const loop = getRalphLoop({
autoGenerateTasks: options.autoGenerate,
});
if (options.minHours) {
loop.setMinDuration(parseFloat(options.minHours));
}
if (loop.isRunning()) {
console.log(chalk.yellow('Ralph loop is already running'));
return;
}
loop.on('taskAssigned', (taskId, sessionId) => {
console.log(chalk.cyan(`→ Task ${taskId.slice(0, 8)} assigned to session ${sessionId.slice(0, 8)}`));
});
loop.on('taskCompleted', (taskId) => {
console.log(chalk.green(`✓ Task ${taskId.slice(0, 8)} completed`));
});
loop.on('taskFailed', (taskId, error) => {
console.log(chalk.red(`✗ Task ${taskId.slice(0, 8)} failed: ${error}`));
});
loop.on('stopped', () => {
console.log(chalk.yellow('\nRalph loop stopped'));
printStats(loop.getStats());
process.exit(0);
});
await loop.start();
console.log(chalk.green('✓ Ralph loop started'));
if (options.minHours) {
console.log(` Minimum duration: ${options.minHours} hours`);
}
console.log(chalk.gray(' Press Ctrl+C to stop\n'));
// Keep process running
process.on('SIGINT', () => {
console.log(chalk.yellow('\nStopping Ralph loop...'));
loop.stop();
});
});
ralphCmd
.command('stop')
.description('Stop the Ralph loop')
.action(() => {
const loop = getRalphLoop();
if (!loop.isRunning()) {
console.log(chalk.yellow('Ralph loop is not running'));
return;
}
loop.stop();
console.log(chalk.green('✓ Ralph loop stopped'));
});
ralphCmd
.command('status')
.description('Show Ralph loop status')
.action(() => {
const loop = getRalphLoop();
const stats = loop.getStats();
printStats(stats);
});
function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats']>) {
const statusColor =
stats.status === 'running' ? chalk.green :
stats.status === 'paused' ? chalk.yellow :
chalk.gray;
console.log(chalk.bold('\nRalph Loop Status:'));
console.log(` Status: ${statusColor(stats.status)}`);
console.log(` Elapsed: ${stats.elapsedHours.toFixed(2)} hours`);
if (stats.minDurationMs) {
const minHours = stats.minDurationMs / (1000 * 60 * 60);
console.log(` Min Duration: ${minHours.toFixed(2)} hours (${stats.minDurationReached ? 'reached' : 'not reached'})`);
}
console.log(chalk.bold('\nTasks:'));
console.log(` Pending: ${stats.pending}`);
console.log(` Running: ${stats.running}`);
console.log(` Completed: ${stats.completed} (${stats.tasksCompleted} this session)`);
console.log(` Failed: ${stats.failed}`);
console.log(` Generated: ${stats.tasksGenerated}`);
console.log(chalk.bold('\nSessions:'));
console.log(` Active: ${stats.activeSessions}`);
console.log(` Idle: ${stats.idleSessions}`);
console.log(` Busy: ${stats.busySessions}`);
console.log('');
}
// ============ Utility Commands ============
program
.command('status')
.description('Show overall status')
.action(() => {
const manager = getSessionManager();
const queue = getTaskQueue();
const loop = getRalphLoop();
const sessions = manager.getAllSessions();
const taskCounts = queue.getCount();
const loopStatus = loop.status;
console.log(chalk.bold('\nClaudeman Status'));
console.log('─'.repeat(40));
console.log(chalk.bold('\nSessions:'));
console.log(` Active: ${sessions.length}`);
console.log(` Idle: ${sessions.filter((s) => s.isIdle()).length}`);
console.log(` Busy: ${sessions.filter((s) => s.isBusy()).length}`);
console.log(chalk.bold('\nTasks:'));
console.log(` Total: ${taskCounts.total}`);
console.log(` Pending: ${taskCounts.pending}`);
console.log(` Running: ${taskCounts.running}`);
console.log(` Completed: ${taskCounts.completed}`);
console.log(` Failed: ${taskCounts.failed}`);
const statusColor =
loopStatus === 'running' ? chalk.green :
loopStatus === 'paused' ? chalk.yellow :
chalk.gray;
console.log(chalk.bold('\nRalph Loop:'));
console.log(` Status: ${statusColor(loopStatus)}`);
console.log('');
});
program
.command('reset')
.description('Reset all state')
.option('-f, --force', 'Skip confirmation')
.action(async (options) => {
if (!options.force) {
console.log(chalk.yellow('This will stop all sessions and clear all state.'));
console.log(chalk.yellow('Use --force to confirm.'));
return;
}
const manager = getSessionManager();
const store = getStore();
await manager.stopAllSessions();
store.reset();
console.log(chalk.green('✓ All state reset'));
});
// Shorthand commands at root level
program
.command('start')
.description('Start a new session (shorthand)')
.option('-d, --dir <path>', 'Working directory', process.cwd())
.action(async (options) => {
const manager = getSessionManager();
const session = await manager.createSession(options.dir);
console.log(chalk.green(`✓ Session started: ${session.id}`));
});
program
.command('list')
.alias('ls')
.description('List all sessions (shorthand)')
.action(() => {
const manager = getSessionManager();
const sessions = manager.getAllSessions();
const stored = manager.getStoredSessions();
if (sessions.length === 0 && Object.keys(stored).length === 0) {
console.log(chalk.yellow('No sessions found'));
return;
}
console.log(chalk.bold('\nActive Sessions:'));
if (sessions.length === 0) {
console.log(' (none)');
} else {
for (const session of sessions) {
const status = session.status === 'idle'
? chalk.green('idle')
: session.status === 'busy'
? chalk.yellow('busy')
: chalk.red(session.status);
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
}
}
console.log('');
});
export { program };
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env node
import { program } from './cli.js';
// Handle uncaught errors
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err.message);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
process.exit(1);
});
// Run CLI
program.parse();
+397
View File
@@ -0,0 +1,397 @@
import { EventEmitter } from 'node:events';
import { getSessionManager, SessionManager } from './session-manager.js';
import { getTaskQueue, TaskQueue } from './task-queue.js';
import { getStore, StateStore } from './state-store.js';
import { Session } from './session.js';
import { Task } from './task.js';
import { RalphLoopStatus } from './types.js';
export interface RalphLoopEvents {
started: () => void;
stopped: () => void;
taskAssigned: (taskId: string, sessionId: string) => void;
taskCompleted: (taskId: string) => void;
taskFailed: (taskId: string, error: string) => void;
error: (error: Error) => void;
}
export interface RalphLoopOptions {
pollIntervalMs?: number;
minDurationMs?: number;
autoGenerateTasks?: boolean;
}
export class RalphLoop extends EventEmitter {
private sessionManager: SessionManager;
private taskQueue: TaskQueue;
private store: StateStore;
private pollIntervalMs: number;
private minDurationMs: number | null;
private autoGenerateTasks: boolean;
private loopTimer: NodeJS.Timeout | null = null;
private _status: RalphLoopStatus = 'stopped';
private startedAt: number | null = null;
private tasksCompleted: number = 0;
private tasksGenerated: number = 0;
constructor(options: RalphLoopOptions = {}) {
super();
this.sessionManager = getSessionManager();
this.taskQueue = getTaskQueue();
this.store = getStore();
const config = this.store.getConfig();
this.pollIntervalMs = options.pollIntervalMs ?? config.pollIntervalMs;
this.minDurationMs = options.minDurationMs ?? null;
this.autoGenerateTasks = options.autoGenerateTasks ?? true;
// Load state from store
const savedState = this.store.getRalphLoopState();
if (savedState.status === 'running') {
// If we crashed while running, reset to stopped
this._status = 'stopped';
this.store.setRalphLoopState({ status: 'stopped' });
}
this.setupEventHandlers();
}
private setupEventHandlers(): void {
this.sessionManager.on('sessionCompletion', (sessionId: string, phrase: string) => {
this.handleSessionCompletion(sessionId, phrase);
});
this.sessionManager.on('sessionError', (sessionId: string, error: string) => {
this.handleSessionError(sessionId, error);
});
this.sessionManager.on('sessionStopped', (sessionId: string) => {
this.handleSessionStopped(sessionId);
});
}
get status(): RalphLoopStatus {
return this._status;
}
isRunning(): boolean {
return this._status === 'running';
}
getElapsedMs(): number {
if (!this.startedAt) {
return 0;
}
return Date.now() - this.startedAt;
}
getElapsedHours(): number {
return this.getElapsedMs() / (1000 * 60 * 60);
}
isMinDurationReached(): boolean {
if (!this.minDurationMs) {
return true;
}
return this.getElapsedMs() >= this.minDurationMs;
}
getStats() {
const taskCounts = this.taskQueue.getCount();
return {
status: this._status,
elapsedMs: this.getElapsedMs(),
elapsedHours: this.getElapsedHours(),
minDurationMs: this.minDurationMs,
minDurationReached: this.isMinDurationReached(),
tasksCompleted: this.tasksCompleted,
tasksGenerated: this.tasksGenerated,
...taskCounts,
activeSessions: this.sessionManager.getSessionCount(),
idleSessions: this.sessionManager.getIdleSessions().length,
busySessions: this.sessionManager.getBusySessions().length,
};
}
async start(): Promise<void> {
if (this._status === 'running') {
return;
}
this._status = 'running';
this.startedAt = Date.now();
this.tasksCompleted = 0;
this.tasksGenerated = 0;
this.store.setRalphLoopState({
status: 'running',
startedAt: this.startedAt,
minDurationMs: this.minDurationMs,
tasksCompleted: 0,
tasksGenerated: 0,
});
this.emit('started');
this.runLoop();
}
stop(): void {
if (this._status === 'stopped') {
return;
}
this._status = 'stopped';
if (this.loopTimer) {
clearTimeout(this.loopTimer);
this.loopTimer = null;
}
this.store.setRalphLoopState({
status: 'stopped',
lastCheckAt: Date.now(),
});
this.emit('stopped');
}
pause(): void {
if (this._status !== 'running') {
return;
}
this._status = 'paused';
if (this.loopTimer) {
clearTimeout(this.loopTimer);
this.loopTimer = null;
}
this.store.setRalphLoopState({ status: 'paused' });
}
resume(): void {
if (this._status !== 'paused') {
return;
}
this._status = 'running';
this.store.setRalphLoopState({ status: 'running' });
this.runLoop();
}
private runLoop(): void {
if (this._status !== 'running') {
return;
}
this.tick()
.catch((err) => {
this.emit('error', err);
})
.finally(() => {
if (this._status === 'running') {
this.loopTimer = setTimeout(() => this.runLoop(), this.pollIntervalMs);
}
});
}
private async tick(): Promise<void> {
this.store.setRalphLoopState({ lastCheckAt: Date.now() });
// Check for timed out tasks
await this.checkTimeouts();
// Assign tasks to idle sessions
await this.assignTasks();
// Check if we should auto-generate tasks
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
await this.generateFollowUpTasks();
}
// Check if we're done
if (this.shouldStop()) {
this.stop();
}
}
private async assignTasks(): Promise<void> {
const idleSessions = this.sessionManager.getIdleSessions();
for (const session of idleSessions) {
const task = this.taskQueue.next();
if (!task) {
break;
}
await this.assignTaskToSession(task, session);
}
}
private async assignTaskToSession(task: Task, session: Session): Promise<void> {
try {
task.assign(session.id);
session.assignTask(task.id);
this.taskQueue.updateTask(task);
// Send the prompt to the session
await session.sendInput(task.prompt);
this.emit('taskAssigned', task.id, session.id);
} catch (err) {
task.fail((err as Error).message);
session.clearTask();
this.taskQueue.updateTask(task);
this.emit('taskFailed', task.id, (err as Error).message);
}
}
private async checkTimeouts(): Promise<void> {
for (const task of this.taskQueue.getRunningTasks()) {
if (task.isTimedOut()) {
task.fail('Task timed out');
this.taskQueue.updateTask(task);
const session = this.sessionManager.getSession(task.assignedSessionId!);
if (session) {
session.clearTask();
}
this.emit('taskFailed', task.id, 'Task timed out');
}
}
}
private handleSessionCompletion(sessionId: string, phrase: string): void {
const session = this.sessionManager.getSession(sessionId);
if (!session) {
return;
}
const taskId = session.currentTaskId;
if (!taskId) {
return;
}
const task = this.taskQueue.getTask(taskId);
if (!task) {
return;
}
// Append output and check for completion
task.appendOutput(session.getOutput());
if (task.checkCompletion(session.getOutput()) || phrase) {
task.complete();
this.taskQueue.updateTask(task);
session.clearTask();
this.tasksCompleted++;
this.store.setRalphLoopState({ tasksCompleted: this.tasksCompleted });
this.emit('taskCompleted', task.id);
}
}
private handleSessionError(sessionId: string, error: string): void {
const session = this.sessionManager.getSession(sessionId);
if (!session) {
return;
}
const taskId = session.currentTaskId;
if (!taskId) {
return;
}
const task = this.taskQueue.getTask(taskId);
if (!task) {
return;
}
task.setError(error);
// Don't fail the task immediately on stderr - some tools write to stderr normally
}
private handleSessionStopped(sessionId: string): void {
const task = this.taskQueue.getRunningTaskForSession(sessionId);
if (task) {
task.fail('Session stopped unexpectedly');
this.taskQueue.updateTask(task);
this.emit('taskFailed', task.id, 'Session stopped unexpectedly');
}
}
private shouldGenerateTasks(): boolean {
// Generate tasks if:
// 1. No pending tasks
// 2. Min duration not reached
// 3. We have idle sessions
const counts = this.taskQueue.getCount();
return (
counts.pending === 0 &&
!this.isMinDurationReached() &&
this.sessionManager.getIdleSessions().length > 0
);
}
private async generateFollowUpTasks(): Promise<void> {
// This is a placeholder for auto-generating follow-up tasks
// In a real implementation, this could:
// - Analyze completed tasks to find optimization opportunities
// - Generate tasks for code cleanup, tests, documentation
// - Use Claude to suggest improvements
const suggestions = [
'Review and optimize recently changed code',
'Add tests for uncovered code paths',
'Update documentation for changed APIs',
'Check for security vulnerabilities',
'Run linting and fix any issues',
];
// Only generate one task at a time
const suggestion = suggestions[this.tasksGenerated % suggestions.length];
const defaultDir = process.cwd();
this.taskQueue.addTask({
prompt: suggestion,
workingDir: defaultDir,
priority: -1, // Lower priority than user-added tasks
});
this.tasksGenerated++;
this.store.setRalphLoopState({ tasksGenerated: this.tasksGenerated });
}
private shouldStop(): boolean {
const counts = this.taskQueue.getCount();
// Don't stop if there are pending or running tasks
if (counts.pending > 0 || counts.running > 0) {
return false;
}
// Don't stop if min duration not reached and auto-generate is on
if (!this.isMinDurationReached() && this.autoGenerateTasks) {
return false;
}
// All tasks done and conditions met
return true;
}
setMinDuration(hours: number): void {
this.minDurationMs = hours * 60 * 60 * 1000;
this.store.setRalphLoopState({ minDurationMs: this.minDurationMs });
}
}
// Singleton instance
let loopInstance: RalphLoop | null = null;
export function getRalphLoop(options?: RalphLoopOptions): RalphLoop {
if (!loopInstance) {
loopInstance = new RalphLoop(options);
}
return loopInstance;
}
+158
View File
@@ -0,0 +1,158 @@
import { EventEmitter } from 'node:events';
import { Session } from './session.js';
import { getStore } from './state-store.js';
import { SessionState } from './types.js';
export interface SessionManagerEvents {
sessionStarted: (session: Session) => void;
sessionStopped: (sessionId: string) => void;
sessionError: (sessionId: string, error: string) => void;
sessionOutput: (sessionId: string, output: string) => void;
sessionCompletion: (sessionId: string, phrase: string) => void;
}
export class SessionManager extends EventEmitter {
private sessions: Map<string, Session> = new Map();
private store = getStore();
constructor() {
super();
this.loadFromStore();
}
private loadFromStore(): void {
const storedSessions = this.store.getSessions();
// Note: We don't restore actual processes, just the state
// Dead sessions are marked as stopped
for (const [id, state] of Object.entries(storedSessions)) {
if (state.status !== 'stopped') {
state.status = 'stopped';
state.pid = null;
this.store.setSession(id, state);
}
}
}
async createSession(workingDir: string): Promise<Session> {
const config = this.store.getConfig();
if (this.sessions.size >= config.maxConcurrentSessions) {
throw new Error(`Maximum concurrent sessions (${config.maxConcurrentSessions}) reached`);
}
const session = new Session({ workingDir });
// Set up event forwarding
session.on('output', (data) => {
this.emit('sessionOutput', session.id, data);
this.updateSessionState(session);
});
session.on('error', (data) => {
this.emit('sessionError', session.id, data);
this.updateSessionState(session);
});
session.on('completion', (phrase) => {
this.emit('sessionCompletion', session.id, phrase);
});
session.on('exit', () => {
this.emit('sessionStopped', session.id);
this.updateSessionState(session);
});
await session.start();
this.sessions.set(session.id, session);
this.store.setSession(session.id, session.toState());
this.emit('sessionStarted', session);
return session;
}
async stopSession(id: string): Promise<void> {
const session = this.sessions.get(id);
if (!session) {
// Update store to mark as stopped if it exists there
const storedSession = this.store.getSession(id);
if (storedSession) {
storedSession.status = 'stopped';
storedSession.pid = null;
this.store.setSession(id, storedSession);
}
return;
}
await session.stop();
this.sessions.delete(id);
this.updateSessionState(session);
}
async stopAllSessions(): Promise<void> {
const stopPromises = Array.from(this.sessions.keys()).map((id) =>
this.stopSession(id)
);
await Promise.all(stopPromises);
}
getSession(id: string): Session | undefined {
return this.sessions.get(id);
}
getAllSessions(): Session[] {
return Array.from(this.sessions.values());
}
getIdleSessions(): Session[] {
return this.getAllSessions().filter((s) => s.isIdle());
}
getBusySessions(): Session[] {
return this.getAllSessions().filter((s) => s.isBusy());
}
getSessionCount(): number {
return this.sessions.size;
}
hasSession(id: string): boolean {
return this.sessions.has(id);
}
private updateSessionState(session: Session): void {
this.store.setSession(session.id, session.toState());
}
getStoredSessions(): Record<string, SessionState> {
return this.store.getSessions();
}
async sendToSession(sessionId: string, input: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
await session.sendInput(input);
}
getSessionOutput(sessionId: string): string | null {
const session = this.sessions.get(sessionId);
return session?.getOutput() ?? null;
}
getSessionError(sessionId: string): string | null {
const session = this.sessions.get(sessionId);
return session?.getError() ?? null;
}
}
// Singleton instance
let managerInstance: SessionManager | null = null;
export function getSessionManager(): SessionManager {
if (!managerInstance) {
managerInstance = new SessionManager();
}
return managerInstance;
}
+235
View File
@@ -0,0 +1,235 @@
import { spawn, ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { v4 as uuidv4 } from 'uuid';
import { SessionState, SessionStatus, SessionConfig } from './types.js';
export interface SessionEvents {
output: (data: string) => void;
error: (data: string) => void;
exit: (code: number | null) => void;
completion: (phrase: string) => void;
}
export class Session extends EventEmitter {
readonly id: string;
readonly workingDir: string;
readonly createdAt: number;
private process: ChildProcess | null = null;
private _status: SessionStatus = 'idle';
private _currentTaskId: string | null = null;
private _outputBuffer: string = '';
private _errorBuffer: string = '';
private _lastActivityAt: number;
constructor(config: Partial<SessionConfig> & { workingDir: string }) {
super();
this.id = config.id || uuidv4();
this.workingDir = config.workingDir;
this.createdAt = config.createdAt || Date.now();
this._lastActivityAt = this.createdAt;
}
get status(): SessionStatus {
return this._status;
}
get currentTaskId(): string | null {
return this._currentTaskId;
}
get pid(): number | null {
return this.process?.pid ?? null;
}
get outputBuffer(): string {
return this._outputBuffer;
}
get errorBuffer(): string {
return this._errorBuffer;
}
get lastActivityAt(): number {
return this._lastActivityAt;
}
isIdle(): boolean {
return this._status === 'idle';
}
isBusy(): boolean {
return this._status === 'busy';
}
isRunning(): boolean {
return this._status === 'idle' || this._status === 'busy';
}
toState(): SessionState {
return {
id: this.id,
pid: this.pid,
status: this._status,
workingDir: this.workingDir,
currentTaskId: this._currentTaskId,
createdAt: this.createdAt,
lastActivityAt: this._lastActivityAt,
};
}
start(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.process) {
reject(new Error('Session already started'));
return;
}
try {
// Spawn claude CLI with --print flag for non-interactive mode
this.process = spawn('claude', ['--print'], {
cwd: this.workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
this._status = 'idle';
this._lastActivityAt = Date.now();
this.process.stdout?.on('data', (data: Buffer) => {
const text = data.toString();
this._outputBuffer += text;
this._lastActivityAt = Date.now();
this.emit('output', text);
this.checkForCompletion(text);
});
this.process.stderr?.on('data', (data: Buffer) => {
const text = data.toString();
this._errorBuffer += text;
this._lastActivityAt = Date.now();
this.emit('error', text);
});
this.process.on('error', (err) => {
this._status = 'error';
this.emit('error', err.message);
reject(err);
});
this.process.on('exit', (code) => {
this._status = 'stopped';
this.process = null;
this.emit('exit', code);
});
// Give process time to start
setTimeout(() => {
if (this.process && !this.process.killed) {
resolve();
}
}, 100);
} catch (err) {
this._status = 'error';
reject(err);
}
});
}
stop(): Promise<void> {
return new Promise((resolve) => {
if (!this.process) {
this._status = 'stopped';
resolve();
return;
}
const cleanup = () => {
this.process = null;
this._status = 'stopped';
this._currentTaskId = null;
resolve();
};
this.process.once('exit', cleanup);
// Try graceful shutdown first
this.process.kill('SIGTERM');
// Force kill after timeout
setTimeout(() => {
if (this.process && !this.process.killed) {
this.process.kill('SIGKILL');
}
}, 5000);
});
}
async sendInput(input: string): Promise<void> {
if (!this.process || !this.process.stdin) {
throw new Error('Session not started or stdin not available');
}
this._status = 'busy';
this._lastActivityAt = Date.now();
return new Promise((resolve, reject) => {
this.process!.stdin!.write(input + '\n', (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
assignTask(taskId: string): void {
this._currentTaskId = taskId;
this._status = 'busy';
this._outputBuffer = '';
this._errorBuffer = '';
this._lastActivityAt = Date.now();
}
clearTask(): void {
this._currentTaskId = null;
this._status = 'idle';
this._lastActivityAt = Date.now();
}
getOutput(): string {
return this._outputBuffer;
}
getError(): string {
return this._errorBuffer;
}
clearBuffers(): void {
this._outputBuffer = '';
this._errorBuffer = '';
}
private checkForCompletion(text: string): void {
// Check for completion phrase pattern: <promise>...</promise>
const promiseMatch = text.match(/<promise>([^<]+)<\/promise>/);
if (promiseMatch) {
this.emit('completion', promiseMatch[1]);
}
// Also check for common completion indicators
const completionIndicators = [
/Task completed successfully/i,
/All tasks done/i,
/✓ Complete/i,
];
for (const pattern of completionIndicators) {
if (pattern.test(text)) {
this.emit('completion', 'auto-detected');
break;
}
}
}
}
+123
View File
@@ -0,0 +1,123 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { AppState, createInitialState } from './types.js';
export class StateStore {
private state: AppState;
private filePath: string;
constructor(filePath?: string) {
this.filePath = filePath || join(homedir(), '.claudeman', 'state.json');
this.state = this.load();
this.state.config.stateFilePath = this.filePath;
}
private ensureDir(): void {
const dir = dirname(this.filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
private load(): AppState {
try {
if (existsSync(this.filePath)) {
const data = readFileSync(this.filePath, 'utf-8');
const parsed = JSON.parse(data) as Partial<AppState>;
// Merge with initial state to ensure all fields exist
const initial = createInitialState();
return {
...initial,
...parsed,
sessions: { ...parsed.sessions },
tasks: { ...parsed.tasks },
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
config: { ...initial.config, ...parsed.config },
};
}
} catch (err) {
console.error('Failed to load state, using initial state:', err);
}
return createInitialState();
}
save(): void {
this.ensureDir();
writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf-8');
}
getState(): AppState {
return this.state;
}
getSessions() {
return this.state.sessions;
}
getSession(id: string) {
return this.state.sessions[id] || null;
}
setSession(id: string, session: AppState['sessions'][string]) {
this.state.sessions[id] = session;
this.save();
}
removeSession(id: string) {
delete this.state.sessions[id];
this.save();
}
getTasks() {
return this.state.tasks;
}
getTask(id: string) {
return this.state.tasks[id] || null;
}
setTask(id: string, task: AppState['tasks'][string]) {
this.state.tasks[id] = task;
this.save();
}
removeTask(id: string) {
delete this.state.tasks[id];
this.save();
}
getRalphLoopState() {
return this.state.ralphLoop;
}
setRalphLoopState(ralphLoop: Partial<AppState['ralphLoop']>) {
this.state.ralphLoop = { ...this.state.ralphLoop, ...ralphLoop };
this.save();
}
getConfig() {
return this.state.config;
}
setConfig(config: Partial<AppState['config']>) {
this.state.config = { ...this.state.config, ...config };
this.save();
}
reset(): void {
this.state = createInitialState();
this.state.config.stateFilePath = this.filePath;
this.save();
}
}
// Singleton instance
let storeInstance: StateStore | null = null;
export function getStore(filePath?: string): StateStore {
if (!storeInstance) {
storeInstance = new StateStore(filePath);
}
return storeInstance;
}
+179
View File
@@ -0,0 +1,179 @@
import { EventEmitter } from 'node:events';
import { Task, CreateTaskOptions } from './task.js';
import { getStore } from './state-store.js';
import { TaskState } from './types.js';
export interface TaskQueueEvents {
taskAdded: (task: Task) => void;
taskRemoved: (taskId: string) => void;
taskUpdated: (task: Task) => void;
}
export class TaskQueue extends EventEmitter {
private tasks: Map<string, Task> = new Map();
private store = getStore();
constructor() {
super();
this.loadFromStore();
}
private loadFromStore(): void {
const storedTasks = this.store.getTasks();
for (const [id, state] of Object.entries(storedTasks)) {
const task = Task.fromState(state);
this.tasks.set(id, task);
}
}
addTask(options: CreateTaskOptions): Task {
const task = new Task(options);
this.tasks.set(task.id, task);
this.store.setTask(task.id, task.toState());
this.emit('taskAdded', task);
return task;
}
getTask(id: string): Task | undefined {
return this.tasks.get(id);
}
removeTask(id: string): boolean {
const removed = this.tasks.delete(id);
if (removed) {
this.store.removeTask(id);
this.emit('taskRemoved', id);
}
return removed;
}
updateTask(task: Task): void {
this.tasks.set(task.id, task);
this.store.setTask(task.id, task.toState());
this.emit('taskUpdated', task);
}
getAllTasks(): Task[] {
return Array.from(this.tasks.values());
}
getPendingTasks(): Task[] {
return this.getAllTasks()
.filter((t) => t.isPending())
.sort((a, b) => {
// Sort by priority (higher first), then by creation time (older first)
if (a.priority !== b.priority) {
return b.priority - a.priority;
}
return a.createdAt - b.createdAt;
});
}
getRunningTasks(): Task[] {
return this.getAllTasks().filter((t) => t.isRunning());
}
getCompletedTasks(): Task[] {
return this.getAllTasks().filter((t) => t.isCompleted());
}
getFailedTasks(): Task[] {
return this.getAllTasks().filter((t) => t.isFailed());
}
hasNext(): boolean {
return this.getNextAvailable() !== null;
}
getNextAvailable(): Task | null {
const pending = this.getPendingTasks();
for (const task of pending) {
if (this.areDependenciesSatisfied(task)) {
return task;
}
}
return null;
}
next(): Task | null {
return this.getNextAvailable();
}
private areDependenciesSatisfied(task: Task): boolean {
for (const depId of task.dependencies) {
const dep = this.tasks.get(depId);
if (!dep || !dep.isCompleted()) {
return false;
}
}
return true;
}
getTasksBySession(sessionId: string): Task[] {
return this.getAllTasks().filter((t) => t.assignedSessionId === sessionId);
}
getRunningTaskForSession(sessionId: string): Task | null {
return this.getAllTasks().find(
(t) => t.isRunning() && t.assignedSessionId === sessionId
) || null;
}
getCount(): { total: number; pending: number; running: number; completed: number; failed: number } {
const tasks = this.getAllTasks();
return {
total: tasks.length,
pending: tasks.filter((t) => t.isPending()).length,
running: tasks.filter((t) => t.isRunning()).length,
completed: tasks.filter((t) => t.isCompleted()).length,
failed: tasks.filter((t) => t.isFailed()).length,
};
}
clearCompleted(): number {
let count = 0;
for (const task of this.getAllTasks()) {
if (task.isCompleted()) {
this.removeTask(task.id);
count++;
}
}
return count;
}
clearFailed(): number {
let count = 0;
for (const task of this.getAllTasks()) {
if (task.isFailed()) {
this.removeTask(task.id);
count++;
}
}
return count;
}
clearAll(): number {
const count = this.tasks.size;
for (const id of this.tasks.keys()) {
this.store.removeTask(id);
}
this.tasks.clear();
return count;
}
getStoredTasks(): Record<string, TaskState> {
return this.store.getTasks();
}
}
// Singleton instance
let queueInstance: TaskQueue | null = null;
export function getTaskQueue(): TaskQueue {
if (!queueInstance) {
queueInstance = new TaskQueue();
}
return queueInstance;
}
+186
View File
@@ -0,0 +1,186 @@
import { v4 as uuidv4 } from 'uuid';
import { TaskDefinition, TaskState, TaskStatus } from './types.js';
export interface CreateTaskOptions {
prompt: string;
workingDir?: string;
priority?: number;
dependencies?: string[];
completionPhrase?: string;
timeoutMs?: number;
}
export class Task {
readonly id: string;
readonly prompt: string;
readonly workingDir: string;
readonly priority: number;
readonly dependencies: string[];
readonly completionPhrase: string | undefined;
readonly timeoutMs: number | undefined;
readonly createdAt: number;
private _status: TaskStatus = 'pending';
private _assignedSessionId: string | null = null;
private _startedAt: number | null = null;
private _completedAt: number | null = null;
private _output: string = '';
private _error: string | null = null;
constructor(options: CreateTaskOptions, id?: string) {
this.id = id || uuidv4();
this.prompt = options.prompt;
this.workingDir = options.workingDir || process.cwd();
this.priority = options.priority ?? 0;
this.dependencies = options.dependencies || [];
this.completionPhrase = options.completionPhrase;
this.timeoutMs = options.timeoutMs;
this.createdAt = Date.now();
}
get status(): TaskStatus {
return this._status;
}
get assignedSessionId(): string | null {
return this._assignedSessionId;
}
get startedAt(): number | null {
return this._startedAt;
}
get completedAt(): number | null {
return this._completedAt;
}
get output(): string {
return this._output;
}
get error(): string | null {
return this._error;
}
isPending(): boolean {
return this._status === 'pending';
}
isRunning(): boolean {
return this._status === 'running';
}
isCompleted(): boolean {
return this._status === 'completed';
}
isFailed(): boolean {
return this._status === 'failed';
}
isDone(): boolean {
return this._status === 'completed' || this._status === 'failed';
}
toDefinition(): TaskDefinition {
return {
id: this.id,
prompt: this.prompt,
workingDir: this.workingDir,
priority: this.priority,
dependencies: this.dependencies,
completionPhrase: this.completionPhrase,
timeoutMs: this.timeoutMs,
};
}
toState(): TaskState {
return {
...this.toDefinition(),
status: this._status,
assignedSessionId: this._assignedSessionId,
createdAt: this.createdAt,
startedAt: this._startedAt,
completedAt: this._completedAt,
output: this._output,
error: this._error,
};
}
static fromState(state: TaskState): Task {
const task = new Task(
{
prompt: state.prompt,
workingDir: state.workingDir,
priority: state.priority,
dependencies: state.dependencies,
completionPhrase: state.completionPhrase,
timeoutMs: state.timeoutMs,
},
state.id
);
task._status = state.status;
task._assignedSessionId = state.assignedSessionId;
task._startedAt = state.startedAt;
task._completedAt = state.completedAt;
task._output = state.output;
task._error = state.error;
return task;
}
assign(sessionId: string): void {
if (this._status !== 'pending') {
throw new Error(`Cannot assign task ${this.id}: status is ${this._status}`);
}
this._status = 'running';
this._assignedSessionId = sessionId;
this._startedAt = Date.now();
}
appendOutput(output: string): void {
this._output += output;
}
setError(error: string): void {
this._error = error;
}
complete(): void {
this._status = 'completed';
this._completedAt = Date.now();
}
fail(error?: string): void {
this._status = 'failed';
this._completedAt = Date.now();
if (error) {
this._error = error;
}
}
reset(): void {
this._status = 'pending';
this._assignedSessionId = null;
this._startedAt = null;
this._completedAt = null;
this._output = '';
this._error = null;
}
checkCompletion(output: string): boolean {
if (this.completionPhrase) {
// Check for exact completion phrase in promise tags
const pattern = new RegExp(`<promise>${this.completionPhrase}</promise>`);
return pattern.test(output);
}
// Default: check for any promise tag completion
return /<promise>[^<]+<\/promise>/.test(output);
}
isTimedOut(): boolean {
if (!this.timeoutMs || !this._startedAt) {
return false;
}
return Date.now() - this._startedAt > this.timeoutMs;
}
}
+104
View File
@@ -0,0 +1,104 @@
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed';
export type RalphLoopStatus = 'stopped' | 'running' | 'paused';
export interface SessionConfig {
id: string;
workingDir: string;
createdAt: number;
}
export interface SessionState {
id: string;
pid: number | null;
status: SessionStatus;
workingDir: string;
currentTaskId: string | null;
createdAt: number;
lastActivityAt: number;
}
export interface TaskDefinition {
id: string;
prompt: string;
workingDir: string;
priority: number;
dependencies: string[];
completionPhrase?: string;
timeoutMs?: number;
}
export interface TaskState {
id: string;
prompt: string;
workingDir: string;
priority: number;
dependencies: string[];
completionPhrase?: string;
timeoutMs?: number;
status: TaskStatus;
assignedSessionId: string | null;
createdAt: number;
startedAt: number | null;
completedAt: number | null;
output: string;
error: string | null;
}
export interface RalphLoopState {
status: RalphLoopStatus;
startedAt: number | null;
minDurationMs: number | null;
tasksCompleted: number;
tasksGenerated: number;
lastCheckAt: number | null;
}
export interface AppState {
sessions: Record<string, SessionState>;
tasks: Record<string, TaskState>;
ralphLoop: RalphLoopState;
config: AppConfig;
}
export interface AppConfig {
pollIntervalMs: number;
defaultTimeoutMs: number;
maxConcurrentSessions: number;
stateFilePath: string;
}
export interface SessionOutput {
stdout: string;
stderr: string;
exitCode: number | null;
}
export interface TaskAssignment {
sessionId: string;
taskId: string;
assignedAt: number;
}
export const DEFAULT_CONFIG: AppConfig = {
pollIntervalMs: 1000,
defaultTimeoutMs: 300000, // 5 minutes
maxConcurrentSessions: 5,
stateFilePath: '',
};
export function createInitialState(): AppState {
return {
sessions: {},
tasks: {},
ralphLoop: {
status: 'stopped',
startedAt: null,
minDurationMs: null,
tasksCompleted: 0,
tasksGenerated: 0,
lastCheckAt: null,
},
config: { ...DEFAULT_CONFIG },
};
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}