summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/release.yml132
-rw-r--r--Dockerfile6
-rw-r--r--makima/README.md57
-rw-r--r--makima/docs/CLI.md669
-rw-r--r--makima/frontend/src/components/SupervisorQuestionNotification.tsx106
-rw-r--r--makima/frontend/src/components/mesh/TaskOutput.tsx151
-rw-r--r--makima/frontend/src/contexts/SupervisorQuestionsContext.tsx20
-rw-r--r--makima/frontend/src/routes/contracts.tsx64
-rw-r--r--makima/frontend/src/routes/mesh.tsx15
-rw-r--r--makima/migrations/20250117000000_add_autonomous_loop.sql13
-rw-r--r--makima/src/bin/makima.rs21
-rw-r--r--makima/src/daemon/api/supervisor.rs11
-rw-r--r--makima/src/daemon/cli/daemon.rs5
-rw-r--r--makima/src/daemon/cli/mod.rs6
-rw-r--r--makima/src/daemon/cli/supervisor.rs26
-rw-r--r--makima/src/daemon/config.rs43
-rw-r--r--makima/src/daemon/process/claude.rs373
-rw-r--r--makima/src/daemon/task/completion_gate.rs402
-rw-r--r--makima/src/daemon/task/manager.rs500
-rw-r--r--makima/src/daemon/task/mod.rs2
-rw-r--r--makima/src/daemon/task/state.rs2
-rw-r--r--makima/src/daemon/ws/protocol.rs22
-rw-r--r--makima/src/db/models.rs13
-rw-r--r--makima/src/db/repository.rs42
-rw-r--r--makima/src/server/handlers/contract_chat.rs1
-rw-r--r--makima/src/server/handlers/contracts.rs116
-rw-r--r--makima/src/server/handlers/mesh_supervisor.rs39
-rw-r--r--makima/src/server/handlers/transcript_analysis.rs1
-rw-r--r--makima/src/server/state.rs9
29 files changed, 2624 insertions, 243 deletions
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..84d340d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,132 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ build:
+ name: Build ${{ matrix.target }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-gnu
+ os: ubuntu-latest
+ artifact_name: makima
+ asset_name: makima-${{ github.ref_name }}-linux-x86_64
+ - target: x86_64-apple-darwin
+ os: macos-15-intel
+ artifact_name: makima
+ asset_name: makima-${{ github.ref_name }}-macos-x86_64
+ - target: aarch64-apple-darwin
+ os: macos-14
+ artifact_name: makima
+ asset_name: makima-${{ github.ref_name }}-macos-arm64
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@nightly
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Install dependencies (Linux)
+ if: runner.os == 'Linux'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y pkg-config libssl-dev
+
+ - name: Build release binary
+ working-directory: makima
+ run: cargo build --release --target ${{ matrix.target }}
+
+ - name: Package binary
+ shell: bash
+ run: |
+ cd target/${{ matrix.target }}/release
+ if [ "${{ runner.os }}" = "Windows" ]; then
+ 7z a ../../../${{ matrix.asset_name }}.zip ${{ matrix.artifact_name }}.exe
+ else
+ tar czvf ../../../${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }}
+ fi
+ cd -
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.asset_name }}
+ path: ${{ matrix.asset_name }}.tar.gz
+
+ release:
+ name: Create Release
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts
+
+ - name: List artifacts
+ run: find artifacts -type f
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v2
+ with:
+ draft: false
+ prerelease: false
+ generate_release_notes: false
+ body: |
+ ## Makima CLI v${{ github.ref_name }}
+
+ Initial release of the Makima CLI - a unified command-line interface for the Makima platform.
+
+ ### Available Commands
+
+ - **`makima server`** - Run the Makima server for audio processing and API endpoints
+ - **`makima daemon`** - Run the daemon that connects to the server and executes tasks
+ - **`makima supervisor`** - Supervisor commands for managing tasks and contracts
+ - **`makima contract`** - Contract-related commands for task tracking and reporting
+
+ ### Installation
+
+ Download the appropriate binary for your platform and add it to your PATH:
+
+ ```bash
+ # Linux x86_64
+ curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/makima-${{ github.ref_name }}-linux-x86_64.tar.gz
+ tar xzf makima-${{ github.ref_name }}-linux-x86_64.tar.gz
+ sudo mv makima /usr/local/bin/
+
+ # macOS Intel
+ curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/makima-${{ github.ref_name }}-macos-x86_64.tar.gz
+ tar xzf makima-${{ github.ref_name }}-macos-x86_64.tar.gz
+ sudo mv makima /usr/local/bin/
+
+ # macOS Apple Silicon
+ curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/makima-${{ github.ref_name }}-macos-arm64.tar.gz
+ tar xzf makima-${{ github.ref_name }}-macos-arm64.tar.gz
+ sudo mv makima /usr/local/bin/
+ ```
+
+ ### Verification
+
+ After installation, verify with:
+ ```bash
+ makima --help
+ ```
+ files: |
+ artifacts/**/*.tar.gz
diff --git a/Dockerfile b/Dockerfile
index d1e7269..e6c3d8b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,7 +18,7 @@ RUN chmod +x /app/download-models.sh
ARG MODEL_BASE_URL
ENV MODEL_BASE_URL=${MODEL_BASE_URL}
-ENV MODELS_DIR=/app/models
+ENV MODELS_DIR=/models
RUN /app/download-models.sh echo "Models downloaded"
# Copy workspace files
@@ -29,7 +29,7 @@ COPY tools/stt-client ./tools/stt-client
# Build release binary
RUN cargo build --release --package makima --bin makima
-RUN mv /app/target/release/makima /app/makima
+RUN cp /app/target/release/makima /makima
# Clean up build artifacts to reduce image size
RUN rm -rf /app/target /app/makima/src /app/vendor /app/tools /usr/local/cargo/registry
@@ -46,4 +46,4 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:${PORT}/api/v1/healthcheck || exit 1
-CMD ["/app/makima"]
+CMD ["/makima", "server"]
diff --git a/makima/README.md b/makima/README.md
index e3e0472..9340da8 100644
--- a/makima/README.md
+++ b/makima/README.md
@@ -7,3 +7,60 @@ Makima is listening
---
Espionage, cybersecurity and surveillance technology
+
+## Makima CLI
+
+Makima provides a unified command-line interface for server management, daemon workers, and task orchestration.
+
+### Quick Start
+
+```bash
+# Build and install
+cd makima
+cargo install --path .
+
+# Start the server
+makima server --port 8080 --database-url "postgresql://localhost/makima"
+
+# Connect a daemon worker
+export MAKIMA_API_KEY=your-api-key
+makima daemon --server-url ws://localhost:8080
+```
+
+### Commands
+
+| Command | Description |
+|---------|-------------|
+| `makima server` | Run the HTTP/WebSocket server |
+| `makima daemon` | Connect to server and manage tasks |
+| `makima supervisor` | Contract orchestration (tasks, branches, PRs) |
+| `makima contract` | Task-contract interaction (status, files, progress) |
+
+### Documentation
+
+See [docs/CLI.md](docs/CLI.md) for comprehensive CLI documentation including:
+
+- Complete command reference
+- Configuration file examples
+- Environment variables
+- Usage workflows
+
+### Configuration
+
+Create `makima-daemon.toml` for daemon configuration:
+
+```toml
+[server]
+url = "ws://localhost:8080"
+api_key = "your-api-key"
+
+[process]
+max_concurrent_tasks = 4
+```
+
+Or use environment variables:
+
+```bash
+export MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080
+export MAKIMA_API_KEY=your-api-key
+```
diff --git a/makima/docs/CLI.md b/makima/docs/CLI.md
new file mode 100644
index 0000000..5246a30
--- /dev/null
+++ b/makima/docs/CLI.md
@@ -0,0 +1,669 @@
+# Makima CLI Reference
+
+Makima is a unified CLI for server, daemon, and task management. It provides commands for running the Makima server, connecting daemon workers, and orchestrating contracts and tasks.
+
+## Table of Contents
+
+- [Installation](#installation)
+- [Commands Overview](#commands-overview)
+- [Command Reference](#command-reference)
+ - [makima server](#makima-server)
+ - [makima daemon](#makima-daemon)
+ - [makima supervisor](#makima-supervisor)
+ - [makima contract](#makima-contract)
+- [Configuration](#configuration)
+ - [Configuration File](#configuration-file)
+ - [Environment Variables](#environment-variables)
+- [Usage Examples](#usage-examples)
+ - [Starting the Server](#starting-the-server)
+ - [Running a Daemon Worker](#running-a-daemon-worker)
+ - [Contract Orchestration Workflow](#contract-orchestration-workflow)
+ - [Task-Contract Interaction](#task-contract-interaction)
+
+---
+
+## Installation
+
+Build and install the Makima CLI from source:
+
+```bash
+cd makima
+cargo build --release
+cargo install --path .
+```
+
+Or install directly:
+
+```bash
+cargo install --path makima
+```
+
+The `makima` binary will be available in your PATH after installation.
+
+---
+
+## Commands Overview
+
+| Command | Description |
+|---------|-------------|
+| `makima server` | Run the HTTP/WebSocket server |
+| `makima daemon` | Connect to server and manage tasks |
+| `makima supervisor` | Contract orchestration commands |
+| `makima contract` | Task-contract interaction commands |
+
+---
+
+## Command Reference
+
+### makima server
+
+Run the Makima HTTP/WebSocket server that coordinates daemons and manages contracts.
+
+```bash
+makima server [OPTIONS]
+```
+
+#### Options
+
+| Option | Environment Variable | Default | Description |
+|--------|---------------------|---------|-------------|
+| `--port <PORT>` | `PORT` | `8080` | Server port |
+| `--parakeet-model-dir <PATH>` | `PARAKEET_MODEL_DIR` | `models/parakeet-tdt-0.6b-v3` | Path to Parakeet model directory |
+| `--parakeet-eou-dir <PATH>` | `PARAKEET_EOU_DIR` | `models/realtime_eou_120m-v1-onnx` | Path to Parakeet EOU model directory |
+| `--sortformer-model-path <PATH>` | `SORTFORMER_MODEL_PATH` | `models/diarization/diar_streaming_sortformer_4spk-v2.1.onnx` | Path to Sortformer model |
+| `--database-url <URL>` | `POSTGRES_CONNECTION_URI` | - | PostgreSQL connection URI |
+| `-l, --log-level <LEVEL>` | - | `info` | Log level (trace, debug, info, warn, error) |
+
+#### Examples
+
+```bash
+# Start server on default port
+makima server
+
+# Start server on custom port with database
+makima server --port 3000 --database-url "postgresql://user:pass@localhost/makima"
+
+# Start server with custom model paths
+makima server --parakeet-model-dir /path/to/models/parakeet
+```
+
+---
+
+### makima daemon
+
+Run the daemon worker that connects to the Makima server and executes tasks.
+
+```bash
+makima daemon [OPTIONS]
+```
+
+#### Options
+
+| Option | Environment Variable | Default | Description |
+|--------|---------------------|---------|-------------|
+| `-c, --config <PATH>` | - | - | Path to custom config file |
+| `--repos-dir <PATH>` | `MAKIMA_DAEMON_REPOS_DIR` | `~/.makima/repos` | Directory where repositories are cloned |
+| `--worktrees-dir <PATH>` | `MAKIMA_DAEMON_WORKTREES_DIR` | `~/.makima/worktrees` | Directory where worktrees are created |
+| `--server-url <URL>` | `MAKIMA_DAEMON_SERVER_URL` | `wss://api.makima.jp` | WebSocket server URL |
+| `--api-key <KEY>` | `MAKIMA_DAEMON_SERVER_APIKEY` | - | API key for server authentication |
+| `--max-tasks <N>` | - | `4` | Maximum number of concurrent tasks |
+| `-l, --log-level <LEVEL>` | - | `info` | Log level (trace, debug, info, warn, error) |
+
+#### Examples
+
+```bash
+# Start daemon with CLI arguments
+makima daemon --server-url ws://localhost:8080 --api-key your-api-key
+
+# Start daemon with custom config file
+makima daemon --config /path/to/config.toml
+
+# Start daemon with environment variables
+export MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080
+export MAKIMA_API_KEY=your-api-key
+makima daemon
+```
+
+---
+
+### makima supervisor
+
+Supervisor commands for contract orchestration. These commands are used by orchestrators to manage tasks, branches, and pull requests.
+
+```bash
+makima supervisor <SUBCOMMAND> [OPTIONS]
+```
+
+#### Common Options
+
+All supervisor subcommands accept these common options:
+
+| Option | Environment Variable | Default | Description |
+|--------|---------------------|---------|-------------|
+| `--api-url <URL>` | `MAKIMA_API_URL` | `http://localhost:8080` | API URL |
+| `--api-key <KEY>` | `MAKIMA_API_KEY` | - | API key for authentication |
+| `--contract-id <UUID>` | `MAKIMA_CONTRACT_ID` | - | Contract ID |
+| `--task-id <UUID>` | `MAKIMA_TASK_ID` | - | Current task ID (optional) |
+
+#### Subcommands
+
+##### tasks
+
+List all tasks in the contract.
+
+```bash
+makima supervisor tasks [OPTIONS]
+```
+
+##### tree
+
+Get the task tree structure showing parent-child relationships.
+
+```bash
+makima supervisor tree [OPTIONS]
+```
+
+##### spawn
+
+Create and start a new task.
+
+```bash
+makima supervisor spawn <NAME> <PLAN> [OPTIONS]
+```
+
+| Argument/Option | Description |
+|-----------------|-------------|
+| `<NAME>` | Name of the task |
+| `<PLAN>` | Plan/description for the task |
+| `--parent <UUID>` | Parent task ID to branch from |
+| `--checkpoint <SHA>` | Checkpoint SHA to start from |
+| `--repo <URL>` | Repository URL (local path or remote) |
+
+##### wait
+
+Wait for a task to complete.
+
+```bash
+makima supervisor wait <TASK_ID> [TIMEOUT]
+```
+
+| Argument | Default | Description |
+|----------|---------|-------------|
+| `<TASK_ID>` | - | Task ID to wait for |
+| `<TIMEOUT>` | `300` | Timeout in seconds |
+
+##### read-file
+
+Read a file from a task's worktree.
+
+```bash
+makima supervisor read-file <TASK_ID> <FILE_PATH>
+```
+
+##### branch
+
+Create a git branch.
+
+```bash
+makima supervisor branch <NAME> [OPTIONS]
+```
+
+| Argument/Option | Description |
+|-----------------|-------------|
+| `<NAME>` | Branch name to create |
+| `--from <REF>` | Reference (task ID or SHA) to branch from |
+
+##### merge
+
+Merge a task's changes to a branch.
+
+```bash
+makima supervisor merge <TASK_ID> [OPTIONS]
+```
+
+| Option | Description |
+|--------|-------------|
+| `--to <BRANCH>` | Target branch to merge into |
+| `--squash` | Squash commits on merge |
+
+##### pr
+
+Create a pull request from a task's changes.
+
+```bash
+makima supervisor pr <TASK_ID> [OPTIONS]
+```
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `--title <TITLE>` | - | PR title (required) |
+| `--body <BODY>` | - | PR body/description |
+| `--base <BRANCH>` | `main` | Base branch |
+
+##### diff
+
+View a task's diff.
+
+```bash
+makima supervisor diff <TASK_ID>
+```
+
+##### checkpoint
+
+Create a checkpoint (save point) for the current task.
+
+```bash
+makima supervisor checkpoint <MESSAGE>
+```
+
+##### checkpoints
+
+List all checkpoints for the current task.
+
+```bash
+makima supervisor checkpoints
+```
+
+##### status
+
+Get contract status including current phase.
+
+```bash
+makima supervisor status
+```
+
+##### advance-phase
+
+Advance the contract to the next phase.
+
+```bash
+makima supervisor advance-phase <PHASE>
+```
+
+| Argument | Description |
+|----------|-------------|
+| `<PHASE>` | Phase to advance to (specify, plan, execute, review) |
+
+##### ask
+
+Ask a question and wait for user feedback.
+
+```bash
+makima supervisor ask <QUESTION> [OPTIONS]
+```
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `--choices <CHOICES>` | - | Comma-separated list of choices |
+| `--context <CONTEXT>` | - | Context about what this relates to |
+| `--timeout <SECONDS>` | `3600` | Timeout in seconds (default: 1 hour) |
+
+---
+
+### makima contract
+
+Contract commands for task-contract interaction. These commands are used by tasks to interact with their parent contract.
+
+```bash
+makima contract <SUBCOMMAND> [OPTIONS]
+```
+
+#### Common Options
+
+All contract subcommands accept these common options:
+
+| Option | Environment Variable | Default | Description |
+|--------|---------------------|---------|-------------|
+| `--api-url <URL>` | `MAKIMA_API_URL` | `http://localhost:8080` | API URL |
+| `--api-key <KEY>` | `MAKIMA_API_KEY` | - | API key for authentication |
+| `--contract-id <UUID>` | `MAKIMA_CONTRACT_ID` | - | Contract ID |
+| `--task-id <UUID>` | `MAKIMA_TASK_ID` | - | Current task ID (optional) |
+
+#### Subcommands
+
+##### status
+
+Get the contract status.
+
+```bash
+makima contract status
+```
+
+Returns JSON with contract name, description, current phase, and status.
+
+##### checklist
+
+Get the phase checklist with deliverables.
+
+```bash
+makima contract checklist
+```
+
+Returns JSON with completion percentage, file deliverables, and suggestions.
+
+##### goals
+
+Get the contract goals.
+
+```bash
+makima contract goals
+```
+
+##### files
+
+List all contract files.
+
+```bash
+makima contract files
+```
+
+##### file
+
+Get a specific file's content.
+
+```bash
+makima contract file <FILE_ID>
+```
+
+##### report
+
+Report progress on the contract.
+
+```bash
+makima contract report <MESSAGE>
+```
+
+##### suggest-action
+
+Get a suggested next action based on contract state.
+
+```bash
+makima contract suggest-action
+```
+
+##### completion-action
+
+Get a completion recommendation with metrics.
+
+```bash
+makima contract completion-action [OPTIONS]
+```
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `--files <FILES>` | - | Comma-separated list of modified files |
+| `--lines-added <N>` | `0` | Number of lines added |
+| `--lines-removed <N>` | `0` | Number of lines removed |
+| `--code` | - | Flag indicating code changes |
+
+##### update-file
+
+Update an existing contract file (reads content from stdin).
+
+```bash
+cat content.md | makima contract update-file <FILE_ID>
+```
+
+##### create-file
+
+Create a new contract file (reads content from stdin).
+
+```bash
+cat content.md | makima contract create-file <NAME>
+```
+
+---
+
+## Configuration
+
+### Configuration File
+
+The daemon loads configuration from multiple sources in order of precedence (highest first):
+
+1. CLI arguments
+2. Environment variables
+3. Custom config file (if `--config` specified)
+4. `./makima-daemon.toml` (current directory)
+5. `~/.config/makima-daemon/config.toml` (user config)
+6. `/etc/makima-daemon/config.toml` (system config, Linux only)
+7. Default values
+
+#### Example Configuration File
+
+Create `makima-daemon.toml`:
+
+```toml
+# Server connection settings
+[server]
+url = "ws://localhost:8080"
+api_key = "your-api-key"
+heartbeat_interval_secs = 30
+reconnect_interval_secs = 5
+max_reconnect_attempts = 0 # 0 = infinite
+
+# Worktree settings for task isolation
+[worktree]
+base_dir = "~/.makima/worktrees"
+repos_dir = "~/.makima/repos"
+branch_prefix = "makima/task-"
+cleanup_on_start = false
+
+# Process settings for Claude Code execution
+[process]
+claude_command = "claude"
+claude_args = []
+claude_pre_args = []
+enable_permissions = false
+disable_verbose = false
+max_concurrent_tasks = 4
+default_timeout_secs = 0 # 0 = no timeout
+
+# Additional environment variables for Claude Code
+[process.env_vars]
+ANTHROPIC_API_KEY = "your-anthropic-key"
+
+# Local database settings
+[local_db]
+path = "~/.makima/daemon.db"
+
+# Logging settings
+[logging]
+level = "info" # trace, debug, info, warn, error
+format = "pretty" # pretty or json
+
+# Repository auto-clone settings
+[repos]
+home_dir = "~/.makima/home"
+
+# Auto-clone repositories on daemon startup
+[[repos.auto_clone]]
+url = "https://github.com/user/repo.git"
+branch = "main"
+shallow = true
+
+# Shorthand format also supported
+[[repos.auto_clone]]
+url = "github:user/another-repo"
+name = "custom-name"
+```
+
+### Environment Variables
+
+#### Daemon Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `MAKIMA_API_KEY` | API key for authentication (preferred) |
+| `MAKIMA_DAEMON_SERVER_URL` | WebSocket server URL |
+| `MAKIMA_DAEMON_SERVER_APIKEY` | API key (alternative) |
+| `MAKIMA_DAEMON_REPOS_DIR` | Repository directory |
+| `MAKIMA_DAEMON_WORKTREES_DIR` | Worktrees directory |
+| `MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS` | Max concurrent tasks |
+
+#### Server Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `PORT` | Server port |
+| `POSTGRES_CONNECTION_URI` | PostgreSQL connection URI |
+| `PARAKEET_MODEL_DIR` | Parakeet model directory |
+| `PARAKEET_EOU_DIR` | Parakeet EOU model directory |
+| `SORTFORMER_MODEL_PATH` | Sortformer model path |
+
+#### Supervisor/Contract Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `MAKIMA_API_URL` | API URL for commands |
+| `MAKIMA_API_KEY` | API key for authentication |
+| `MAKIMA_CONTRACT_ID` | Current contract ID |
+| `MAKIMA_TASK_ID` | Current task ID |
+
+---
+
+## Usage Examples
+
+### Starting the Server
+
+```bash
+# Start with defaults
+makima server
+
+# Start with database
+makima server --database-url "postgresql://localhost/makima" --port 8080
+
+# Production setup
+makima server \
+ --port 8080 \
+ --database-url "$DATABASE_URL" \
+ --log-level info
+```
+
+### Running a Daemon Worker
+
+```bash
+# Quick start with CLI args
+makima daemon \
+ --server-url ws://localhost:8080 \
+ --api-key your-api-key \
+ --max-tasks 4
+
+# Using environment variables
+export MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080
+export MAKIMA_API_KEY=your-api-key
+makima daemon
+
+# Using a config file
+makima daemon --config /etc/makima/daemon.toml
+```
+
+### Contract Orchestration Workflow
+
+```bash
+# Set up environment
+export MAKIMA_API_URL=http://localhost:8080
+export MAKIMA_API_KEY=your-api-key
+export MAKIMA_CONTRACT_ID=your-contract-uuid
+
+# Check contract status
+makima supervisor status
+
+# List existing tasks
+makima supervisor tasks
+
+# View task tree
+makima supervisor tree
+
+# Spawn a new task
+makima supervisor spawn "Implement feature X" "Add the new feature with tests"
+
+# Wait for task completion
+makima supervisor wait <task-id> 600
+
+# View task diff
+makima supervisor diff <task-id>
+
+# Create a PR
+makima supervisor pr <task-id> \
+ --title "Add feature X" \
+ --body "Implements feature X with full test coverage" \
+ --base main
+
+# Or merge directly
+makima supervisor merge <task-id> --to main --squash
+
+# Ask user a question
+makima supervisor ask "Which approach should we use?" \
+ --choices "Option A,Option B,Option C" \
+ --timeout 3600
+```
+
+### Task-Contract Interaction
+
+When running inside a task, use contract commands to interact with the parent contract:
+
+```bash
+# Get contract context
+makima contract status
+
+# Check phase requirements
+makima contract checklist
+
+# List contract files
+makima contract files
+
+# Read a specific file
+makima contract file <file-id>
+
+# Report progress
+makima contract report "Completed initial implementation"
+
+# Get suggested next action
+makima contract suggest-action
+
+# Create a new documentation file
+echo "# Architecture\n\nSystem design..." | makima contract create-file "Architecture"
+
+# Update an existing file
+cat updated_doc.md | makima contract update-file <file-id>
+
+# Report completion with metrics
+makima contract completion-action \
+ --files "src/main.rs,src/lib.rs" \
+ --lines-added 150 \
+ --lines-removed 30 \
+ --code
+```
+
+---
+
+## Output Format
+
+All commands output JSON to stdout for easy parsing and integration:
+
+```bash
+# Parse with jq
+makima supervisor tasks | jq '.tasks[].name'
+
+# Get specific fields
+makima contract status | jq '{name: .name, phase: .phase}'
+
+# Check completion
+makima contract checklist | jq '.completion_percentage'
+```
+
+---
+
+## Exit Codes
+
+| Code | Description |
+|------|-------------|
+| `0` | Success |
+| `1` | General error (authentication failed, invalid arguments, etc.) |
+
+---
+
+## See Also
+
+- [Makima README](../README.md) - Project overview
+- [Task Branching Plan](./PLAN-task-branching.md) - Task isolation architecture
diff --git a/makima/frontend/src/components/SupervisorQuestionNotification.tsx b/makima/frontend/src/components/SupervisorQuestionNotification.tsx
index 6a71de2..1457d86 100644
--- a/makima/frontend/src/components/SupervisorQuestionNotification.tsx
+++ b/makima/frontend/src/components/SupervisorQuestionNotification.tsx
@@ -1,50 +1,22 @@
-import { useState } from "react";
import { useNavigate } from "react-router";
import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";
-import type { PendingQuestion } from "../lib/api";
export function SupervisorQuestionNotification() {
const navigate = useNavigate();
- const { pendingQuestions, submitAnswer } = useSupervisorQuestions();
- const [expandedQuestion, setExpandedQuestion] = useState<string | null>(null);
- const [response, setResponse] = useState("");
- const [submitting, setSubmitting] = useState(false);
+ const { notificationQuestions, dismissNotification } = useSupervisorQuestions();
- if (pendingQuestions.length === 0) {
+ if (notificationQuestions.length === 0) {
return null;
}
- const handleGoToTask = (taskId: string) => {
+ const handleGoToTask = (questionId: string, taskId: string) => {
+ dismissNotification(questionId);
navigate(`/mesh/${taskId}`);
};
- const handleExpand = (questionId: string) => {
- setExpandedQuestion(expandedQuestion === questionId ? null : questionId);
- setResponse("");
- };
-
- const handleSubmit = async (question: PendingQuestion) => {
- if (!response.trim()) return;
-
- setSubmitting(true);
- const success = await submitAnswer(question.questionId, response.trim());
- setSubmitting(false);
-
- if (success) {
- setExpandedQuestion(null);
- setResponse("");
- }
- };
-
- const handleChoiceSelect = async (question: PendingQuestion, choice: string) => {
- setSubmitting(true);
- await submitAnswer(question.questionId, choice);
- setSubmitting(false);
- };
-
return (
<div className="fixed bottom-4 right-4 z-50 max-w-md space-y-2">
- {pendingQuestions.map((question) => (
+ {notificationQuestions.map((question) => (
<div
key={question.questionId}
className="bg-[#0d1b2d] border border-amber-500/50 rounded-lg shadow-lg overflow-hidden"
@@ -54,24 +26,15 @@ export function SupervisorQuestionNotification() {
<div className="flex items-center gap-2">
<span className="text-amber-400 text-lg">?</span>
<span className="font-mono text-sm text-amber-300 uppercase">
- Supervisor Question
+ Task needs input
</span>
</div>
- <div className="flex items-center gap-2">
- <button
- onClick={() => handleGoToTask(question.taskId)}
- className="px-2 py-1 font-mono text-xs text-amber-400 hover:text-amber-300 transition-colors"
- title="Go to task"
- >
- View Task
- </button>
- <button
- onClick={() => handleExpand(question.questionId)}
- className="px-2 py-1 font-mono text-xs text-amber-400 border border-amber-500/30 hover:border-amber-400/50 transition-colors uppercase"
- >
- {expandedQuestion === question.questionId ? "Collapse" : "Answer"}
- </button>
- </div>
+ <button
+ onClick={() => handleGoToTask(question.questionId, question.taskId)}
+ className="px-3 py-1 font-mono text-xs text-amber-400 border border-amber-500/30 hover:border-amber-400/50 hover:bg-amber-900/20 transition-colors uppercase"
+ >
+ View Task
+ </button>
</div>
{/* Question preview */}
@@ -81,53 +44,10 @@ export function SupervisorQuestionNotification() {
{question.context}
</div>
)}
- <p className="text-sm text-[#dbe7ff] font-mono">
+ <p className="text-sm text-[#dbe7ff] font-mono line-clamp-2">
{question.question}
</p>
</div>
-
- {/* Expanded answer section */}
- {expandedQuestion === question.questionId && (
- <div className="px-4 pb-4 border-t border-amber-500/20 pt-3">
- {question.choices.length > 0 ? (
- // Choice buttons
- <div className="space-y-2">
- <p className="text-xs text-[#8b949e] font-mono uppercase mb-2">
- Select an option:
- </p>
- {question.choices.map((choice, idx) => (
- <button
- key={idx}
- onClick={() => handleChoiceSelect(question, choice)}
- disabled={submitting}
- className="w-full px-3 py-2 text-left font-mono text-sm text-[#dbe7ff] bg-[#0a1628] border border-[#3f6fb3] hover:border-amber-400/50 hover:bg-amber-900/20 disabled:opacity-50 transition-colors"
- >
- {choice}
- </button>
- ))}
- </div>
- ) : (
- // Free-form text input
- <div className="space-y-2">
- <textarea
- value={response}
- onChange={(e) => setResponse(e.target.value)}
- placeholder="Type your response..."
- rows={3}
- className="w-full px-3 py-2 bg-[#0a1628] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-amber-400 resize-none"
- disabled={submitting}
- />
- <button
- onClick={() => handleSubmit(question)}
- disabled={submitting || !response.trim()}
- className="w-full px-4 py-2 font-mono text-xs text-[#0a1628] bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed transition-colors uppercase"
- >
- {submitting ? "Submitting..." : "Submit Response"}
- </button>
- </div>
- )}
- </div>
- )}
</div>
))}
</div>
diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx
index cb0eba3..d53429d 100644
--- a/makima/frontend/src/components/mesh/TaskOutput.tsx
+++ b/makima/frontend/src/components/mesh/TaskOutput.tsx
@@ -16,9 +16,23 @@ interface TaskOutputProps {
taskId?: string | null;
/** Callback when user sends input (to show it immediately in output) */
onUserInput?: (message: string) => void;
+ /** Set of pending question IDs (for supervisor questions) */
+ pendingQuestionIds?: Set<string>;
+ /** Callback to answer a supervisor question */
+ onAnswerQuestion?: (questionId: string, response: string) => Promise<void>;
}
-export function TaskOutput({ entries, isStreaming, viewingSubtaskName, onClearSubtaskView, onClear, taskId, onUserInput }: TaskOutputProps) {
+export function TaskOutput({
+ entries,
+ isStreaming,
+ viewingSubtaskName,
+ onClearSubtaskView,
+ onClear,
+ taskId,
+ onUserInput,
+ pendingQuestionIds,
+ onAnswerQuestion,
+}: TaskOutputProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const [inputValue, setInputValue] = useState("");
@@ -135,7 +149,12 @@ export function TaskOutput({ entries, isStreaming, viewingSubtaskName, onClearSu
) : (
<div className="space-y-3">
{entries.map((entry, idx) => (
- <OutputEntryRenderer key={idx} entry={entry} />
+ <OutputEntryRenderer
+ key={idx}
+ entry={entry}
+ pendingQuestionIds={pendingQuestionIds}
+ onAnswerQuestion={onAnswerQuestion}
+ />
))}
{isStreaming && (
<span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse" />
@@ -177,7 +196,13 @@ export function TaskOutput({ entries, isStreaming, viewingSubtaskName, onClearSu
);
}
-function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) {
+interface OutputEntryRendererProps {
+ entry: TaskOutputEvent;
+ pendingQuestionIds?: Set<string>;
+ onAnswerQuestion?: (questionId: string, response: string) => Promise<void>;
+}
+
+function OutputEntryRenderer({ entry, pendingQuestionIds, onAnswerQuestion }: OutputEntryRendererProps) {
const [expanded, setExpanded] = useState(false);
switch (entry.messageType) {
@@ -278,11 +303,131 @@ function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) {
case "auth_required":
return <AuthRequiredEntry entry={entry} />;
+ case "supervisor_question":
+ return (
+ <SupervisorQuestionEntry
+ entry={entry}
+ pendingQuestionIds={pendingQuestionIds}
+ onAnswerQuestion={onAnswerQuestion}
+ />
+ );
+
default:
return null;
}
}
+function SupervisorQuestionEntry({
+ entry,
+ pendingQuestionIds,
+ onAnswerQuestion,
+}: {
+ entry: TaskOutputEvent;
+ pendingQuestionIds?: Set<string>;
+ onAnswerQuestion?: (questionId: string, response: string) => Promise<void>;
+}) {
+ const questionId = entry.toolInput?.question_id as string;
+ const choices = (entry.toolInput?.choices as string[]) || [];
+ const context = entry.toolInput?.context as string | null;
+
+ const [customInput, setCustomInput] = useState("");
+ const [showOther, setShowOther] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+
+ const isPending = pendingQuestionIds?.has(questionId) ?? false;
+
+ const handleChoiceSelect = async (choice: string) => {
+ if (!onAnswerQuestion || submitting) return;
+ setSubmitting(true);
+ try {
+ await onAnswerQuestion(questionId, choice);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleOtherSubmit = async () => {
+ if (!onAnswerQuestion || !customInput.trim() || submitting) return;
+ setSubmitting(true);
+ try {
+ await onAnswerQuestion(questionId, customInput.trim());
+ setCustomInput("");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+ <div className="bg-amber-900/20 border border-amber-500/50 rounded p-3 my-2">
+ <div className="flex items-center gap-2 text-amber-400 font-semibold mb-2">
+ <span>?</span>
+ <span>Question</span>
+ {!isPending && (
+ <span className="text-green-400 text-xs font-normal">(Answered)</span>
+ )}
+ </div>
+
+ {context && (
+ <p className="text-amber-200/60 text-xs mb-2 uppercase">{context}</p>
+ )}
+
+ <p className="text-amber-100 mb-3">{entry.content}</p>
+
+ {isPending && (
+ <div className="space-y-2">
+ {choices.length > 0 && (
+ <div className="flex flex-wrap gap-2">
+ {choices.map((choice, idx) => (
+ <button
+ key={idx}
+ onClick={() => handleChoiceSelect(choice)}
+ disabled={submitting}
+ className="px-3 py-1.5 text-sm font-mono bg-amber-500/20 border border-amber-500/50 hover:bg-amber-500/30 disabled:opacity-50 text-amber-100 transition-colors"
+ >
+ {choice}
+ </button>
+ ))}
+ </div>
+ )}
+
+ {/* Other option */}
+ {!showOther ? (
+ <button
+ onClick={() => setShowOther(true)}
+ className="text-xs text-amber-400 hover:text-amber-300 transition-colors"
+ >
+ + Other (custom response)
+ </button>
+ ) : (
+ <div className="flex gap-2">
+ <input
+ type="text"
+ value={customInput}
+ onChange={(e) => setCustomInput(e.target.value)}
+ placeholder="Type custom response..."
+ disabled={submitting}
+ className="flex-1 px-2 py-1 bg-[#0a1525] border border-amber-500/30 text-amber-100 text-sm rounded focus:outline-none focus:border-amber-400"
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && customInput.trim()) {
+ handleOtherSubmit();
+ }
+ }}
+ />
+ <button
+ onClick={handleOtherSubmit}
+ disabled={submitting || !customInput.trim()}
+ className="px-3 py-1 bg-amber-500 text-black text-sm font-medium rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors hover:bg-amber-400"
+ >
+ {submitting ? "..." : "Submit"}
+ </button>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
function AuthRequiredEntry({ entry }: { entry: TaskOutputEvent }) {
const [authCode, setAuthCode] = useState("");
const [submitting, setSubmitting] = useState(false);
diff --git a/makima/frontend/src/contexts/SupervisorQuestionsContext.tsx b/makima/frontend/src/contexts/SupervisorQuestionsContext.tsx
index aa1bb12..712c755 100644
--- a/makima/frontend/src/contexts/SupervisorQuestionsContext.tsx
+++ b/makima/frontend/src/contexts/SupervisorQuestionsContext.tsx
@@ -4,10 +4,14 @@ import { useAuth } from "./AuthContext";
interface SupervisorQuestionsContextValue {
pendingQuestions: PendingQuestion[];
+ /** Questions that are pending but not dismissed from notifications */
+ notificationQuestions: PendingQuestion[];
loading: boolean;
error: string | null;
refreshQuestions: () => Promise<void>;
submitAnswer: (questionId: string, response: string) => Promise<boolean>;
+ /** Dismiss a question from the notification (but keep it pending in task output) */
+ dismissNotification: (questionId: string) => void;
}
const SupervisorQuestionsContext = createContext<SupervisorQuestionsContextValue | null>(null);
@@ -15,9 +19,17 @@ const SupervisorQuestionsContext = createContext<SupervisorQuestionsContextValue
export function SupervisorQuestionsProvider({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth();
const [pendingQuestions, setPendingQuestions] = useState<PendingQuestion[]>([]);
+ const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
+ // Questions that should show in notifications (not dismissed)
+ const notificationQuestions = pendingQuestions.filter(q => !dismissedIds.has(q.questionId));
+
+ const dismissNotification = useCallback((questionId: string) => {
+ setDismissedIds(prev => new Set(prev).add(questionId));
+ }, []);
+
const refreshQuestions = useCallback(async () => {
if (!isAuthenticated) return;
@@ -44,6 +56,12 @@ export function SupervisorQuestionsProvider({ children }: { children: ReactNode
if (result.success) {
// Remove the question from local state
setPendingQuestions(prev => prev.filter(q => q.questionId !== questionId));
+ // Also clean up dismissed state
+ setDismissedIds(prev => {
+ const next = new Set(prev);
+ next.delete(questionId);
+ return next;
+ });
}
return result.success;
} catch (err) {
@@ -74,10 +92,12 @@ export function SupervisorQuestionsProvider({ children }: { children: ReactNode
<SupervisorQuestionsContext.Provider
value={{
pendingQuestions,
+ notificationQuestions,
loading,
error,
refreshQuestions,
submitAnswer,
+ dismissNotification,
}}
>
{children}
diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx
index f09ec5b..5e9bf60 100644
--- a/makima/frontend/src/routes/contracts.tsx
+++ b/makima/frontend/src/routes/contracts.tsx
@@ -6,7 +6,7 @@ import { ContractDetail } from "../components/contracts/ContractDetail";
import { DirectoryInput } from "../components/mesh/DirectoryInput";
import { useContracts } from "../hooks/useContracts";
import { useAuth } from "../contexts/AuthContext";
-import { createTask, getDaemonDirectories } from "../lib/api";
+import { createTask, getDaemonDirectories, getRepositorySuggestions } from "../lib/api";
import type {
ContractWithRelations,
ContractPhase,
@@ -15,6 +15,7 @@ import type {
CreateContractRequest,
RepositorySourceType,
DaemonDirectory,
+ RepositoryHistoryEntry,
} from "../lib/api";
import { getValidPhases, getDefaultPhase } from "../lib/api";
@@ -81,6 +82,38 @@ function ContractsPageContent() {
const [repoPath, setRepoPath] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]);
+ const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]);
+ const [showRepoSuggestions, setShowRepoSuggestions] = useState(false);
+
+ // Fetch repository suggestions when modal opens and repo type changes
+ useEffect(() => {
+ if (isCreating && (repoType === "remote" || repoType === "local")) {
+ getRepositorySuggestions(repoType, undefined, 10)
+ .then((res) => {
+ setRepoSuggestions(res.entries);
+ setShowRepoSuggestions(res.entries.length > 0);
+ })
+ .catch(() => {
+ setRepoSuggestions([]);
+ setShowRepoSuggestions(false);
+ });
+ } else {
+ setRepoSuggestions([]);
+ setShowRepoSuggestions(false);
+ }
+ }, [isCreating, repoType]);
+
+ // Apply a repository suggestion
+ const applyRepoSuggestion = useCallback((suggestion: RepositoryHistoryEntry) => {
+ setRepoName(suggestion.name);
+ if (suggestion.repositoryUrl) {
+ setRepoUrl(suggestion.repositoryUrl);
+ }
+ if (suggestion.localPath) {
+ setRepoPath(suggestion.localPath);
+ }
+ setShowRepoSuggestions(false);
+ }, []);
// Fetch daemon directories when "local" repo type is selected
useEffect(() => {
@@ -540,6 +573,35 @@ function ContractsPageContent() {
</button>
</div>
+ {/* Repository suggestions */}
+ {showRepoSuggestions && repoSuggestions.length > 0 && (
+ <div className="mb-3">
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Recent Repositories
+ </label>
+ <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto">
+ {repoSuggestions.map((suggestion) => (
+ <button
+ key={suggestion.id}
+ type="button"
+ onClick={() => applyRepoSuggestion(suggestion)}
+ className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0"
+ >
+ <div className="flex items-center justify-between">
+ <span className="text-[#9bc3ff] truncate">{suggestion.name}</span>
+ <span className="text-[10px] text-[#556677] ml-2">
+ {suggestion.useCount}×
+ </span>
+ </div>
+ <div className="text-[10px] text-[#556677] truncate">
+ {suggestion.repositoryUrl || suggestion.localPath}
+ </div>
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+
{/* Repository name */}
<div className="mb-3">
<label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx
index ed5a6d0..050381a 100644
--- a/makima/frontend/src/routes/mesh.tsx
+++ b/makima/frontend/src/routes/mesh.tsx
@@ -11,6 +11,7 @@ import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRe
import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories, continueTask as continueTaskApi } from "../lib/api";
import { DirectoryInput } from "../components/mesh/DirectoryInput";
import { useAuth } from "../contexts/AuthContext";
+import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";
// View modes for the task detail page
type ViewMode = "split" | "task" | "output";
@@ -80,6 +81,18 @@ export default function MeshPage() {
const navigate = useNavigate();
const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
const { tasks, loading, error, conflict, clearConflict, fetchTask, fetchTasks, editTask, removeTask, saveTask } = useTasks();
+ const { pendingQuestions, submitAnswer } = useSupervisorQuestions();
+
+ // Memoize pending question IDs for efficient lookup
+ const pendingQuestionIds = useMemo(
+ () => new Set(pendingQuestions.map(q => q.questionId)),
+ [pendingQuestions]
+ );
+
+ // Handler for answering supervisor questions
+ const handleAnswerQuestion = useCallback(async (questionId: string, response: string) => {
+ await submitAnswer(questionId, response);
+ }, [submitAnswer]);
// Redirect to login if not authenticated
useEffect(() => {
@@ -720,6 +733,8 @@ export default function MeshPage() {
}}
taskId={activeOutputTaskId}
onUserInput={handleUserInput}
+ pendingQuestionIds={pendingQuestionIds}
+ onAnswerQuestion={handleAnswerQuestion}
/>
</div>
)}
diff --git a/makima/migrations/20250117000000_add_autonomous_loop.sql b/makima/migrations/20250117000000_add_autonomous_loop.sql
new file mode 100644
index 0000000..2125abf
--- /dev/null
+++ b/makima/migrations/20250117000000_add_autonomous_loop.sql
@@ -0,0 +1,13 @@
+-- Add autonomous_loop column to contracts table
+-- When enabled, tasks for this contract will automatically restart with --continue
+-- if they exit without a COMPLETION_GATE indicating ready: true.
+
+ALTER TABLE contracts
+ADD COLUMN IF NOT EXISTS autonomous_loop BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add autonomous_loop column to tasks table for per-task override
+ALTER TABLE tasks
+ADD COLUMN IF NOT EXISTS autonomous_loop BOOLEAN NOT NULL DEFAULT FALSE;
+
+COMMENT ON COLUMN contracts.autonomous_loop IS 'Whether tasks for this contract should run in autonomous loop mode';
+COMMENT ON COLUMN tasks.autonomous_loop IS 'Whether this task should run in autonomous loop mode (overrides contract setting if set)';
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs
index 4fc331c..f430701 100644
--- a/makima/src/bin/makima.rs
+++ b/makima/src/bin/makima.rs
@@ -78,6 +78,7 @@ async fn run_daemon(
api_key: args.api_key,
max_tasks: args.max_tasks,
log_level: args.log_level,
+ bubblewrap: args.bubblewrap,
};
// Load configuration with CLI overrides
@@ -162,6 +163,11 @@ async fn run_daemon(
let ws_tx = ws_client.sender();
// Create task configuration
+ let bubblewrap_config = if config.process.bubblewrap.enabled {
+ Some(config.process.bubblewrap.clone())
+ } else {
+ None
+ };
let task_config = TaskConfig {
max_concurrent_tasks: config.process.max_concurrent_tasks,
worktree_base_dir: config.worktree.base_dir.clone(),
@@ -171,6 +177,7 @@ async fn run_daemon(
claude_pre_args: config.process.claude_pre_args.clone(),
enable_permissions: config.process.enable_permissions,
disable_verbose: config.process.disable_verbose,
+ bubblewrap: bubblewrap_config,
};
// Create task manager
@@ -309,7 +316,7 @@ async fn run_supervisor(
let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
let task_id = args
.common
- .task_id
+ .self_task_id
.ok_or("MAKIMA_TASK_ID is required for checkpoint")?;
let result = client
.supervisor_checkpoint(task_id, &args.message)
@@ -318,7 +325,7 @@ async fn run_supervisor(
}
SupervisorCommand::Checkpoints(args) => {
let client = ApiClient::new(args.api_url, args.api_key)?;
- let task_id = args.task_id.ok_or("MAKIMA_TASK_ID is required")?;
+ let task_id = args.self_task_id.ok_or("MAKIMA_TASK_ID is required")?;
let result = client.supervisor_checkpoints(task_id).await?;
println!("{}", serde_json::to_string(&result.0)?);
}
@@ -347,6 +354,16 @@ async fn run_supervisor(
.await?;
println!("{}", serde_json::to_string(&result.0)?);
}
+ SupervisorCommand::Task(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client.supervisor_get_task(args.target_task_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ SupervisorCommand::Output(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client.supervisor_get_task_output(args.target_task_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
}
Ok(())
diff --git a/makima/src/daemon/api/supervisor.rs b/makima/src/daemon/api/supervisor.rs
index 0a68980..8b3d480 100644
--- a/makima/src/daemon/api/supervisor.rs
+++ b/makima/src/daemon/api/supervisor.rs
@@ -228,4 +228,15 @@ impl ApiClient {
self.post(&format!("/api/v1/contracts/{}/phase", contract_id), &req)
.await
}
+
+ /// Get individual task details.
+ pub async fn supervisor_get_task(&self, task_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/mesh/tasks/{}", task_id)).await
+ }
+
+ /// Get task output/claude log.
+ pub async fn supervisor_get_task_output(&self, task_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/mesh/tasks/{}/output", task_id))
+ .await
+ }
}
diff --git a/makima/src/daemon/cli/daemon.rs b/makima/src/daemon/cli/daemon.rs
index de4cff4..c779d64 100644
--- a/makima/src/daemon/cli/daemon.rs
+++ b/makima/src/daemon/cli/daemon.rs
@@ -33,4 +33,9 @@ pub struct DaemonArgs {
/// Log level (trace, debug, info, warn, error)
#[arg(short, long, default_value = "info")]
pub log_level: String,
+
+ /// Enable bubblewrap sandbox for Claude processes.
+ /// Requires bwrap to be installed on the system.
+ #[arg(long, env = "MAKIMA_DAEMON_BUBBLEWRAP")]
+ pub bubblewrap: bool,
}
diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs
index 1a49399..da71b0d 100644
--- a/makima/src/daemon/cli/mod.rs
+++ b/makima/src/daemon/cli/mod.rs
@@ -82,6 +82,12 @@ pub enum SupervisorCommand {
/// Ask a question and wait for user feedback
Ask(supervisor::AskArgs),
+
+ /// Get individual task details
+ Task(supervisor::GetTaskArgs),
+
+ /// Get task output/claude log
+ Output(supervisor::GetTaskOutputArgs),
}
/// Contract subcommands for task-contract interaction.
diff --git a/makima/src/daemon/cli/supervisor.rs b/makima/src/daemon/cli/supervisor.rs
index dc534b5..2bc4c89 100644
--- a/makima/src/daemon/cli/supervisor.rs
+++ b/makima/src/daemon/cli/supervisor.rs
@@ -14,9 +14,9 @@ pub struct SupervisorArgs {
#[arg(long, env = "MAKIMA_API_KEY")]
pub api_key: String,
- /// Current task ID (optional)
+ /// Current task ID (optional) - the supervisor's own task ID
#[arg(long, env = "MAKIMA_TASK_ID")]
- pub task_id: Option<Uuid>,
+ pub self_task_id: Option<Uuid>,
/// Contract ID
#[arg(long, env = "MAKIMA_CONTRACT_ID")]
@@ -199,3 +199,25 @@ pub struct AdvancePhaseArgs {
#[arg(index = 1)]
pub phase: String,
}
+
+/// Arguments for task command (get individual task details).
+#[derive(Args, Debug)]
+pub struct GetTaskArgs {
+ #[command(flatten)]
+ pub common: SupervisorArgs,
+
+ /// Task ID to get details for
+ #[arg(index = 1, id = "target_task_id")]
+ pub target_task_id: Uuid,
+}
+
+/// Arguments for output command (get task output/claude log).
+#[derive(Args, Debug)]
+pub struct GetTaskOutputArgs {
+ #[command(flatten)]
+ pub common: SupervisorArgs,
+
+ /// Task ID to get output for
+ #[arg(index = 1, id = "target_task_id")]
+ pub target_task_id: Uuid,
+}
diff --git a/makima/src/daemon/config.rs b/makima/src/daemon/config.rs
index 866ee70..512b822 100644
--- a/makima/src/daemon/config.rs
+++ b/makima/src/daemon/config.rs
@@ -5,6 +5,38 @@ use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
+/// Bubblewrap sandbox configuration for Claude processes.
+#[derive(Debug, Clone, Deserialize, Default)]
+pub struct BubblewrapConfig {
+ /// Enable bubblewrap sandboxing.
+ #[serde(default)]
+ pub enabled: bool,
+
+ /// Path to bwrap binary (default: 'bwrap').
+ #[serde(default = "default_bwrap_command")]
+ pub bwrap_command: String,
+
+ /// Allow network access inside sandbox (default: true).
+ #[serde(default = "default_true")]
+ pub network: bool,
+
+ /// Additional paths to bind read-only.
+ #[serde(default)]
+ pub ro_bind: Vec<PathBuf>,
+
+ /// Additional paths to bind read-write.
+ #[serde(default)]
+ pub rw_bind: Vec<PathBuf>,
+}
+
+fn default_bwrap_command() -> String {
+ "bwrap".to_string()
+}
+
+fn default_true() -> bool {
+ true
+}
+
/// Root daemon configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct DaemonConfig {
@@ -177,6 +209,10 @@ pub struct ProcessConfig {
/// Additional environment variables to pass to Claude Code.
#[serde(default, alias = "envvars")]
pub env_vars: HashMap<String, String>,
+
+ /// Bubblewrap sandbox configuration.
+ #[serde(default)]
+ pub bubblewrap: BubblewrapConfig,
}
fn default_claude_command() -> String {
@@ -198,6 +234,7 @@ impl Default for ProcessConfig {
max_concurrent_tasks: default_max_tasks(),
default_timeout_secs: 0,
env_vars: HashMap::new(),
+ bubblewrap: BubblewrapConfig::default(),
}
}
}
@@ -478,6 +515,11 @@ impl DaemonConfig {
// Log level is always set (has default)
config.logging.level = args.log_level.clone();
+ // Enable bubblewrap if --bubblewrap flag is set
+ if args.bubblewrap {
+ config.process.bubblewrap.enabled = true;
+ }
+
// Validate required fields after all sources are merged
config.validate()?;
@@ -511,6 +553,7 @@ impl DaemonConfig {
max_concurrent_tasks: 2,
default_timeout_secs: 0,
env_vars: HashMap::new(),
+ bubblewrap: BubblewrapConfig::default(),
},
local_db: LocalDbConfig {
path: PathBuf::from("/tmp/makima-daemon-test/state.db"),
diff --git a/makima/src/daemon/process/claude.rs b/makima/src/daemon/process/claude.rs
index 536d883..f3aa421 100644
--- a/makima/src/daemon/process/claude.rs
+++ b/makima/src/daemon/process/claude.rs
@@ -1,7 +1,7 @@
//! Claude Code process management.
use std::collections::HashMap;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
@@ -11,6 +11,7 @@ use tokio::process::{Child, ChildStdin, Command};
use tokio::sync::{mpsc, Mutex};
use super::claude_protocol::ClaudeInputMessage;
+use crate::daemon::config::BubblewrapConfig;
/// Errors that can occur during Claude process management.
#[derive(Debug, thiserror::Error)]
@@ -26,6 +27,9 @@ pub enum ClaudeProcessError {
#[error("Failed to read output: {0}")]
OutputRead(String),
+
+ #[error("Bubblewrap (bwrap) not found. Install bubblewrap or disable the --bubblewrap flag.")]
+ BubblewrapNotFound,
}
/// A line of output from Claude Code.
@@ -234,6 +238,8 @@ pub struct ProcessManager {
disable_verbose: bool,
/// Default environment variables to pass.
default_env: HashMap<String, String>,
+ /// Bubblewrap sandbox configuration.
+ bubblewrap: Option<BubblewrapConfig>,
}
impl Default for ProcessManager {
@@ -252,6 +258,7 @@ impl ProcessManager {
enable_permissions: false,
disable_verbose: false,
default_env: HashMap::new(),
+ bubblewrap: None,
}
}
@@ -264,6 +271,7 @@ impl ProcessManager {
enable_permissions: false,
disable_verbose: false,
default_env: HashMap::new(),
+ bubblewrap: None,
}
}
@@ -297,11 +305,147 @@ impl ProcessManager {
self
}
+ /// Configure bubblewrap sandboxing.
+ ///
+ /// When enabled, Claude processes will be spawned inside a bubblewrap sandbox
+ /// with filesystem isolation.
+ pub fn with_bubblewrap(mut self, config: Option<BubblewrapConfig>) -> Self {
+ self.bubblewrap = config;
+ self
+ }
+
/// Get the claude command path.
pub fn claude_command(&self) -> &str {
&self.claude_command
}
+ /// Check if bubblewrap (bwrap) is available on the system.
+ ///
+ /// Returns the bwrap version string if available.
+ pub async fn check_bwrap_available(&self) -> Result<String, ClaudeProcessError> {
+ let bwrap_cmd = self
+ .bubblewrap
+ .as_ref()
+ .map(|c| c.bwrap_command.as_str())
+ .unwrap_or("bwrap");
+
+ let output = Command::new(bwrap_cmd)
+ .arg("--version")
+ .output()
+ .await
+ .map_err(|e| {
+ if e.kind() == std::io::ErrorKind::NotFound {
+ ClaudeProcessError::BubblewrapNotFound
+ } else {
+ ClaudeProcessError::SpawnFailed(e)
+ }
+ })?;
+
+ if output.status.success() {
+ Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+ } else {
+ Err(ClaudeProcessError::BubblewrapNotFound)
+ }
+ }
+
+ /// Build bwrap command arguments for sandboxing.
+ ///
+ /// Returns a tuple of (bwrap_command, bwrap_args) where bwrap_args includes
+ /// all the sandbox flags followed by "--" and the actual command to run.
+ fn build_bwrap_args(
+ &self,
+ working_dir: &Path,
+ claude_command: &str,
+ claude_args: &[String],
+ config: &BubblewrapConfig,
+ ) -> (String, Vec<String>) {
+ let mut args = Vec::new();
+
+ // Unshare all namespaces except user (for unprivileged use)
+ args.push("--unshare-all".to_string());
+
+ // Share network if enabled (needed for API calls)
+ if config.network {
+ args.push("--share-net".to_string());
+ }
+
+ // Safety flags
+ args.push("--die-with-parent".to_string());
+ args.push("--new-session".to_string());
+
+ // Bind root filesystem read-only
+ args.push("--ro-bind".to_string());
+ args.push("/".to_string());
+ args.push("/".to_string());
+
+ // Mount fresh /dev
+ args.push("--dev".to_string());
+ args.push("/dev".to_string());
+
+ // Mount fresh /proc
+ args.push("--proc".to_string());
+ args.push("/proc".to_string());
+
+ // Fresh /tmp
+ args.push("--tmpfs".to_string());
+ args.push("/tmp".to_string());
+
+ // Bind working directory (worktree) read-write
+ let working_dir_str = working_dir.to_string_lossy().to_string();
+ args.push("--bind".to_string());
+ args.push(working_dir_str.clone());
+ args.push(working_dir_str);
+
+ // Bind ~/.claude read-write if it exists (for Claude config)
+ if let Ok(home) = std::env::var("HOME") {
+ let claude_config_dir = PathBuf::from(&home).join(".claude");
+ if claude_config_dir.exists() {
+ let claude_config_str = claude_config_dir.to_string_lossy().to_string();
+ args.push("--bind".to_string());
+ args.push(claude_config_str.clone());
+ args.push(claude_config_str);
+ }
+
+ // Also bind ~/.config/claude if it exists (alternative config location)
+ let claude_config_alt = PathBuf::from(&home).join(".config").join("claude");
+ if claude_config_alt.exists() {
+ let config_str = claude_config_alt.to_string_lossy().to_string();
+ args.push("--bind".to_string());
+ args.push(config_str.clone());
+ args.push(config_str);
+ }
+ }
+
+ // Additional read-only binds from config
+ for path in &config.ro_bind {
+ if path.exists() {
+ let path_str = path.to_string_lossy().to_string();
+ args.push("--ro-bind".to_string());
+ args.push(path_str.clone());
+ args.push(path_str);
+ }
+ }
+
+ // Additional read-write binds from config
+ for path in &config.rw_bind {
+ if path.exists() {
+ let path_str = path.to_string_lossy().to_string();
+ args.push("--bind".to_string());
+ args.push(path_str.clone());
+ args.push(path_str);
+ }
+ }
+
+ // Separator before the actual command
+ args.push("--".to_string());
+
+ // Add the claude command and its arguments
+ args.push(claude_command.to_string());
+ args.extend(claude_args.iter().cloned());
+
+ (config.bwrap_command.clone(), args)
+ }
+
/// Spawn a Claude Code process to execute a plan.
///
/// The process runs in the specified working directory with stream-json output format.
@@ -327,11 +471,25 @@ impl ProcessManager {
extra_env: Option<HashMap<String, String>>,
system_prompt: Option<&str>,
) -> Result<ClaudeProcess, ClaudeProcessError> {
+ // Check if bubblewrap is enabled and available
+ let use_bubblewrap = if let Some(ref bwrap_config) = self.bubblewrap {
+ if bwrap_config.enabled {
+ // Verify bwrap is available before proceeding
+ self.check_bwrap_available().await?;
+ true
+ } else {
+ false
+ }
+ } else {
+ false
+ };
+
tracing::info!(
working_dir = %working_dir.display(),
plan_len = plan.len(),
plan_preview = %if plan.len() > 200 { &plan[..200] } else { plan },
has_system_prompt = system_prompt.is_some(),
+ bubblewrap_enabled = use_bubblewrap,
"Spawning Claude Code process"
);
@@ -350,37 +508,52 @@ impl ProcessManager {
env.extend(extra);
}
- // Build arguments list
- let mut args = Vec::new();
+ // Build Claude arguments list
+ let mut claude_args = Vec::new();
// Pre-args (before defaults)
- args.extend(self.claude_pre_args.clone());
+ claude_args.extend(self.claude_pre_args.clone());
// Required arguments for stream-json protocol
- args.push("--output-format=stream-json".to_string());
- args.push("--input-format=stream-json".to_string());
+ claude_args.push("--output-format=stream-json".to_string());
+ claude_args.push("--input-format=stream-json".to_string());
// Optional default arguments
if !self.disable_verbose {
- args.push("--verbose".to_string());
+ claude_args.push("--verbose".to_string());
}
if !self.enable_permissions {
- args.push("--dangerously-skip-permissions".to_string());
+ claude_args.push("--dangerously-skip-permissions".to_string());
}
// System prompt - passed via --system-prompt flag for system-level constraints
if let Some(prompt) = system_prompt {
- args.push("--system-prompt".to_string());
- args.push(prompt.to_string());
+ claude_args.push("--system-prompt".to_string());
+ claude_args.push(prompt.to_string());
}
// Additional user-configured arguments
- args.extend(self.claude_args.clone());
-
- tracing::debug!(args = ?args, "Claude command arguments");
+ claude_args.extend(self.claude_args.clone());
+
+ // Determine the actual command and arguments to spawn
+ let (command, args) = if use_bubblewrap {
+ let bwrap_config = self.bubblewrap.as_ref().unwrap();
+ let (bwrap_cmd, bwrap_args) =
+ self.build_bwrap_args(working_dir, &self.claude_command, &claude_args, bwrap_config);
+ tracing::info!(
+ bwrap_command = %bwrap_cmd,
+ bwrap_args_count = bwrap_args.len(),
+ "Running Claude in bubblewrap sandbox"
+ );
+ tracing::debug!(bwrap_args = ?bwrap_args, "Bubblewrap arguments");
+ (bwrap_cmd, bwrap_args)
+ } else {
+ tracing::debug!(args = ?claude_args, "Claude command arguments");
+ (self.claude_command.clone(), claude_args)
+ };
// Spawn the process
- let mut child = Command::new(&self.claude_command)
+ let mut child = Command::new(&command)
.args(&args)
.current_dir(working_dir)
.envs(env)
@@ -391,7 +564,11 @@ impl ProcessManager {
.spawn()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
- ClaudeProcessError::CommandNotFound(self.claude_command.clone())
+ if use_bubblewrap {
+ ClaudeProcessError::BubblewrapNotFound
+ } else {
+ ClaudeProcessError::CommandNotFound(self.claude_command.clone())
+ }
} else {
ClaudeProcessError::SpawnFailed(e)
}
@@ -487,6 +664,172 @@ impl ProcessManager {
Ok(process)
}
+ /// Spawn a Claude Code process in continuation mode.
+ ///
+ /// This is used for the autonomous loop feature where we need to continue
+ /// a previous conversation. The --continue flag tells Claude to resume
+ /// from the previous session state.
+ pub async fn spawn_continue(
+ &self,
+ working_dir: &Path,
+ continuation_prompt: &str,
+ extra_env: Option<HashMap<String, String>>,
+ system_prompt: Option<&str>,
+ ) -> Result<ClaudeProcess, ClaudeProcessError> {
+ tracing::info!(
+ working_dir = %working_dir.display(),
+ prompt_len = continuation_prompt.len(),
+ has_system_prompt = system_prompt.is_some(),
+ "Spawning Claude Code process in continuation mode"
+ );
+
+ // Verify working directory exists
+ if !working_dir.exists() {
+ tracing::error!(working_dir = %working_dir.display(), "Working directory does not exist!");
+ return Err(ClaudeProcessError::SpawnFailed(std::io::Error::new(
+ std::io::ErrorKind::NotFound,
+ format!("Working directory does not exist: {}", working_dir.display()),
+ )));
+ }
+
+ // Build environment
+ let mut env = self.default_env.clone();
+ if let Some(extra) = extra_env {
+ env.extend(extra);
+ }
+
+ // Build arguments list
+ let mut args = Vec::new();
+
+ // Pre-args (before defaults)
+ args.extend(self.claude_pre_args.clone());
+
+ // Required arguments for stream-json protocol
+ args.push("--output-format=stream-json".to_string());
+ args.push("--input-format=stream-json".to_string());
+
+ // The key flag for continuation mode
+ args.push("--continue".to_string());
+
+ // Optional default arguments
+ if !self.disable_verbose {
+ args.push("--verbose".to_string());
+ }
+ if !self.enable_permissions {
+ args.push("--dangerously-skip-permissions".to_string());
+ }
+
+ // System prompt - passed via --system-prompt flag for system-level constraints
+ if let Some(prompt) = system_prompt {
+ args.push("--system-prompt".to_string());
+ args.push(prompt.to_string());
+ }
+
+ // Additional user-configured arguments
+ args.extend(self.claude_args.clone());
+
+ tracing::debug!(args = ?args, "Claude continue command arguments");
+
+ // Spawn the process
+ let mut child = Command::new(&self.claude_command)
+ .args(&args)
+ .current_dir(working_dir)
+ .envs(env)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .kill_on_drop(true)
+ .spawn()
+ .map_err(|e| {
+ if e.kind() == std::io::ErrorKind::NotFound {
+ ClaudeProcessError::CommandNotFound(self.claude_command.clone())
+ } else {
+ ClaudeProcessError::SpawnFailed(e)
+ }
+ })?;
+
+ // Create output channel
+ let (tx, rx) = mpsc::channel(1000);
+
+ // Take stdout, stderr, and stdin
+ let stdin = child.stdin.take();
+ let stdin = Arc::new(Mutex::new(stdin));
+
+ let stdout = child.stdout.take().expect("stdout should be piped");
+ let stderr = child.stderr.take().expect("stderr should be piped");
+
+ // Spawn task to read stdout
+ let tx_stdout = tx.clone();
+ tokio::spawn(async move {
+ use tokio::io::AsyncReadExt;
+ let mut reader = BufReader::new(stdout);
+ let mut buffer = vec![0u8; 4096];
+ let mut line_buffer = String::new();
+
+ loop {
+ match tokio::time::timeout(
+ tokio::time::Duration::from_secs(5),
+ reader.read(&mut buffer)
+ ).await {
+ Ok(Ok(0)) => {
+ tracing::debug!("Claude stdout EOF (continue mode)");
+ if !line_buffer.is_empty() {
+ let _ = tx_stdout.send(OutputLine::stdout(line_buffer)).await;
+ }
+ break;
+ }
+ Ok(Ok(n)) => {
+ let chunk = String::from_utf8_lossy(&buffer[..n]);
+ line_buffer.push_str(&chunk);
+ while let Some(newline_pos) = line_buffer.find('\n') {
+ let line = line_buffer[..newline_pos].to_string();
+ line_buffer = line_buffer[newline_pos + 1..].to_string();
+ if tx_stdout.send(OutputLine::stdout(line)).await.is_err() {
+ return;
+ }
+ }
+ }
+ Ok(Err(e)) => {
+ tracing::error!(error = %e, "Error reading Claude stdout (continue mode)");
+ break;
+ }
+ Err(_) => {
+ tracing::warn!("No stdout data from Claude for 5 seconds (continue mode)");
+ }
+ }
+ }
+ tracing::debug!("Claude stdout reader task ended (continue mode)");
+ });
+
+ // Spawn task to read stderr
+ let tx_stderr = tx;
+ tokio::spawn(async move {
+ let reader = BufReader::new(stderr);
+ let mut lines = reader.lines();
+ while let Ok(Some(line)) = lines.next_line().await {
+ tracing::debug!(line = %line, "Claude stderr (continue mode)");
+ if tx_stderr.send(OutputLine::stderr(line)).await.is_err() {
+ break;
+ }
+ }
+ tracing::debug!("Claude stderr reader task ended (continue mode)");
+ });
+
+ tracing::info!("Claude Code process spawned successfully in continue mode");
+
+ let process = ClaudeProcess {
+ child,
+ output_rx: rx,
+ stdin,
+ };
+
+ // Send the continuation prompt as a user message
+ tracing::info!(prompt_len = continuation_prompt.len(), "Sending continuation prompt to Claude via stdin");
+ process.send_user_message(continuation_prompt).await?;
+
+ Ok(process)
+ }
+
/// Check if the claude command is available.
pub async fn check_claude_available(&self) -> Result<String, ClaudeProcessError> {
let output = Command::new(&self.claude_command)
diff --git a/makima/src/daemon/task/completion_gate.rs b/makima/src/daemon/task/completion_gate.rs
new file mode 100644
index 0000000..69b7c6a
--- /dev/null
+++ b/makima/src/daemon/task/completion_gate.rs
@@ -0,0 +1,402 @@
+//! Completion gate parsing for autonomous loop mode.
+//!
+//! This module parses COMPLETION_GATE blocks from Claude's output to determine
+//! if the task is truly complete. The format is inspired by Ralph's autonomous
+//! development framework.
+//!
+//! Format:
+//! ```
+//! <COMPLETION_GATE>
+//! ready: true|false
+//! reason: "explanation of completion status"
+//! progress: "summary of what was accomplished"
+//! blockers: ["list", "of", "blockers"] (optional, only when ready: false)
+//! </COMPLETION_GATE>
+//! ```
+
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+
+/// Represents a parsed COMPLETION_GATE block from Claude's output.
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct CompletionGate {
+ /// Whether the task is ready to complete.
+ pub ready: bool,
+ /// Explanation of the completion status.
+ pub reason: Option<String>,
+ /// Summary of what was accomplished.
+ pub progress: Option<String>,
+ /// List of blockers if not ready.
+ pub blockers: Option<Vec<String>>,
+ /// Any additional fields that were parsed.
+ #[serde(flatten)]
+ pub extra: HashMap<String, serde_json::Value>,
+}
+
+impl CompletionGate {
+ /// Parse a COMPLETION_GATE block from text output.
+ ///
+ /// Returns None if no valid COMPLETION_GATE is found.
+ pub fn parse(text: &str) -> Option<Self> {
+ // Find the COMPLETION_GATE block
+ let start_tag = "<COMPLETION_GATE>";
+ let end_tag = "</COMPLETION_GATE>";
+
+ let start_idx = text.find(start_tag)?;
+ let end_idx = text.find(end_tag)?;
+
+ if end_idx <= start_idx {
+ return None;
+ }
+
+ let content = &text[start_idx + start_tag.len()..end_idx];
+ let content = content.trim();
+
+ // Try to parse as JSON first
+ if content.starts_with('{') {
+ if let Ok(gate) = serde_json::from_str::<CompletionGate>(content) {
+ return Some(gate);
+ }
+ }
+
+ // Fall back to YAML-like parsing
+ Self::parse_yaml_like(content)
+ }
+
+ /// Parse a YAML-like format (key: value lines).
+ fn parse_yaml_like(content: &str) -> Option<Self> {
+ let mut gate = CompletionGate::default();
+
+ for line in content.lines() {
+ let line = line.trim();
+ if line.is_empty() {
+ continue;
+ }
+
+ if let Some((key, value)) = line.split_once(':') {
+ let key = key.trim().to_lowercase();
+ let value = value.trim();
+
+ match key.as_str() {
+ "ready" => {
+ gate.ready = value.to_lowercase() == "true"
+ || value == "yes"
+ || value == "1";
+ }
+ "reason" => {
+ gate.reason = Some(Self::unquote(value));
+ }
+ "progress" => {
+ gate.progress = Some(Self::unquote(value));
+ }
+ "blockers" => {
+ // Try to parse as JSON array
+ if let Ok(blockers) = serde_json::from_str::<Vec<String>>(value) {
+ gate.blockers = Some(blockers);
+ } else {
+ // Single blocker as string
+ gate.blockers = Some(vec![Self::unquote(value)]);
+ }
+ }
+ _ => {
+ // Store unknown fields
+ if let Ok(json_val) = serde_json::from_str(value) {
+ gate.extra.insert(key, json_val);
+ } else {
+ gate.extra.insert(
+ key,
+ serde_json::Value::String(Self::unquote(value)),
+ );
+ }
+ }
+ }
+ }
+ }
+
+ Some(gate)
+ }
+
+ /// Remove surrounding quotes from a string value.
+ fn unquote(s: &str) -> String {
+ let s = s.trim();
+ if (s.starts_with('"') && s.ends_with('"'))
+ || (s.starts_with('\'') && s.ends_with('\''))
+ {
+ s[1..s.len() - 1].to_string()
+ } else {
+ s.to_string()
+ }
+ }
+
+ /// Find all COMPLETION_GATE blocks in the output and return the last one.
+ ///
+ /// This is useful when Claude produces multiple completion gates during
+ /// a long-running task, and we want to use the final status.
+ pub fn parse_last(text: &str) -> Option<Self> {
+ let end_tag = "</COMPLETION_GATE>";
+ let mut last_gate = None;
+ let mut search_start = 0;
+
+ while let Some(end_idx) = text[search_start..].find(end_tag) {
+ let absolute_end = search_start + end_idx + end_tag.len();
+ if let Some(gate) = Self::parse(&text[..absolute_end]) {
+ last_gate = Some(gate);
+ }
+ search_start = absolute_end;
+ }
+
+ last_gate
+ }
+}
+
+/// State tracking for the circuit breaker in autonomous loop mode.
+#[derive(Debug, Clone, Default)]
+pub struct CircuitBreaker {
+ /// Number of consecutive runs without file changes.
+ pub runs_without_changes: u32,
+ /// Threshold for opening circuit due to no changes (default: 3).
+ pub no_change_threshold: u32,
+ /// Number of consecutive runs with the same error.
+ pub same_error_count: u32,
+ /// Threshold for opening circuit due to same error (default: 5).
+ pub same_error_threshold: u32,
+ /// Last error message seen.
+ pub last_error: Option<String>,
+ /// Total number of loop iterations.
+ pub iteration_count: u32,
+ /// Maximum allowed iterations (default: 10).
+ pub max_iterations: u32,
+ /// Whether the circuit is open (task should stop).
+ pub is_open: bool,
+ /// Reason why circuit was opened.
+ pub open_reason: Option<String>,
+}
+
+impl CircuitBreaker {
+ /// Create a new circuit breaker with default thresholds.
+ pub fn new() -> Self {
+ Self {
+ no_change_threshold: 3,
+ same_error_threshold: 5,
+ max_iterations: 10,
+ ..Default::default()
+ }
+ }
+
+ /// Create with custom thresholds.
+ pub fn with_thresholds(no_change: u32, same_error: u32, max_iterations: u32) -> Self {
+ Self {
+ no_change_threshold: no_change,
+ same_error_threshold: same_error,
+ max_iterations,
+ ..Default::default()
+ }
+ }
+
+ /// Record a new iteration. Returns true if circuit should remain closed.
+ pub fn record_iteration(&mut self, had_changes: bool, error: Option<&str>) -> bool {
+ self.iteration_count += 1;
+
+ // Check max iterations
+ if self.iteration_count >= self.max_iterations {
+ self.is_open = true;
+ self.open_reason = Some(format!(
+ "Maximum iterations ({}) reached",
+ self.max_iterations
+ ));
+ return false;
+ }
+
+ // Track file changes
+ if had_changes {
+ self.runs_without_changes = 0;
+ } else {
+ self.runs_without_changes += 1;
+ if self.runs_without_changes >= self.no_change_threshold {
+ self.is_open = true;
+ self.open_reason = Some(format!(
+ "No file changes for {} consecutive runs",
+ self.runs_without_changes
+ ));
+ return false;
+ }
+ }
+
+ // Track errors
+ match (error, &self.last_error) {
+ (Some(err), Some(last)) if err == last => {
+ self.same_error_count += 1;
+ if self.same_error_count >= self.same_error_threshold {
+ self.is_open = true;
+ self.open_reason = Some(format!(
+ "Same error repeated {} times: {}",
+ self.same_error_count, err
+ ));
+ return false;
+ }
+ }
+ (Some(err), _) => {
+ self.last_error = Some(err.to_string());
+ self.same_error_count = 1;
+ }
+ (None, _) => {
+ self.same_error_count = 0;
+ self.last_error = None;
+ }
+ }
+
+ true // Circuit remains closed
+ }
+
+ /// Check if the circuit breaker is open.
+ pub fn should_stop(&self) -> bool {
+ self.is_open
+ }
+
+ /// Reset the circuit breaker.
+ pub fn reset(&mut self) {
+ *self = Self::with_thresholds(
+ self.no_change_threshold,
+ self.same_error_threshold,
+ self.max_iterations,
+ );
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_yaml_format() {
+ let text = r#"
+Some output before
+<COMPLETION_GATE>
+ready: true
+reason: "All tests pass"
+progress: "Implemented feature X"
+</COMPLETION_GATE>
+More output after
+"#;
+
+ let gate = CompletionGate::parse(text).unwrap();
+ assert!(gate.ready);
+ assert_eq!(gate.reason.as_deref(), Some("All tests pass"));
+ assert_eq!(gate.progress.as_deref(), Some("Implemented feature X"));
+ }
+
+ #[test]
+ fn test_parse_not_ready() {
+ let text = r#"
+<COMPLETION_GATE>
+ready: false
+reason: "Tests are failing"
+blockers: ["Fix test_foo", "Fix test_bar"]
+</COMPLETION_GATE>
+"#;
+
+ let gate = CompletionGate::parse(text).unwrap();
+ assert!(!gate.ready);
+ assert_eq!(gate.reason.as_deref(), Some("Tests are failing"));
+ assert_eq!(
+ gate.blockers,
+ Some(vec!["Fix test_foo".to_string(), "Fix test_bar".to_string()])
+ );
+ }
+
+ #[test]
+ fn test_parse_json_format() {
+ let text = r#"
+<COMPLETION_GATE>
+{
+ "ready": true,
+ "reason": "Done",
+ "progress": "All good"
+}
+</COMPLETION_GATE>
+"#;
+
+ let gate = CompletionGate::parse(text).unwrap();
+ assert!(gate.ready);
+ assert_eq!(gate.reason.as_deref(), Some("Done"));
+ }
+
+ #[test]
+ fn test_parse_last_gate() {
+ let text = r#"
+<COMPLETION_GATE>
+ready: false
+reason: "Still working"
+</COMPLETION_GATE>
+Some more work...
+<COMPLETION_GATE>
+ready: true
+reason: "Finally done"
+</COMPLETION_GATE>
+"#;
+
+ let gate = CompletionGate::parse_last(text).unwrap();
+ assert!(gate.ready);
+ assert_eq!(gate.reason.as_deref(), Some("Finally done"));
+ }
+
+ #[test]
+ fn test_no_gate() {
+ let text = "No completion gate here";
+ assert!(CompletionGate::parse(text).is_none());
+ }
+
+ #[test]
+ fn test_circuit_breaker_max_iterations() {
+ let mut cb = CircuitBreaker::with_thresholds(3, 5, 5);
+ for _ in 0..4 {
+ assert!(cb.record_iteration(true, None));
+ }
+ assert!(!cb.record_iteration(true, None)); // 5th iteration should trip
+ assert!(cb.is_open);
+ assert!(cb.open_reason.as_ref().unwrap().contains("Maximum iterations"));
+ }
+
+ #[test]
+ fn test_circuit_breaker_no_changes() {
+ let mut cb = CircuitBreaker::with_thresholds(3, 5, 10);
+ assert!(cb.record_iteration(false, None)); // 1st no change
+ assert!(cb.record_iteration(false, None)); // 2nd no change
+ assert!(!cb.record_iteration(false, None)); // 3rd no change - trips
+ assert!(cb.is_open);
+ assert!(cb.open_reason.as_ref().unwrap().contains("No file changes"));
+ }
+
+ #[test]
+ fn test_circuit_breaker_same_error() {
+ let mut cb = CircuitBreaker::with_thresholds(10, 3, 10);
+ let err = "Test failed";
+ assert!(cb.record_iteration(true, Some(err)));
+ assert!(cb.record_iteration(true, Some(err)));
+ assert!(!cb.record_iteration(true, Some(err))); // 3rd same error - trips
+ assert!(cb.is_open);
+ assert!(cb.open_reason.as_ref().unwrap().contains("Same error"));
+ }
+
+ #[test]
+ fn test_circuit_breaker_different_errors_ok() {
+ let mut cb = CircuitBreaker::with_thresholds(10, 3, 10);
+ assert!(cb.record_iteration(true, Some("error 1")));
+ assert!(cb.record_iteration(true, Some("error 2")));
+ assert!(cb.record_iteration(true, Some("error 3")));
+ // Different errors don't trip the circuit
+ assert!(!cb.is_open);
+ }
+
+ #[test]
+ fn test_circuit_breaker_changes_reset() {
+ let mut cb = CircuitBreaker::with_thresholds(3, 5, 10);
+ assert!(cb.record_iteration(false, None)); // 1 no change
+ assert!(cb.record_iteration(false, None)); // 2 no changes
+ assert!(cb.record_iteration(true, None)); // has changes - resets
+ assert!(cb.record_iteration(false, None)); // 1 no change again
+ assert!(cb.record_iteration(false, None)); // 2 no changes
+ // Still shouldn't trip because we had a change in between
+ assert!(!cb.is_open);
+ }
+}
diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs
index 4ccedb2..75c884b 100644
--- a/makima/src/daemon/task/manager.rs
+++ b/makima/src/daemon/task/manager.rs
@@ -12,6 +12,7 @@ use uuid::Uuid;
use std::collections::HashSet;
+use super::completion_gate::{CircuitBreaker, CompletionGate};
use super::state::TaskState;
use crate::daemon::error::{DaemonError, TaskError, TaskResult};
use crate::daemon::process::{ClaudeInputMessage, ProcessManager};
@@ -944,6 +945,8 @@ pub struct ManagedTask {
pub copy_files: Option<Vec<String>>,
/// Contract ID if this task is associated with a contract.
pub contract_id: Option<Uuid>,
+ /// Whether to run in autonomous loop mode.
+ pub autonomous_loop: bool,
/// Time task was created.
pub created_at: Instant,
/// Time task started running.
@@ -973,6 +976,8 @@ pub struct TaskConfig {
pub enable_permissions: bool,
/// Disable verbose output.
pub disable_verbose: bool,
+ /// Bubblewrap sandbox configuration.
+ pub bubblewrap: Option<crate::daemon::config::BubblewrapConfig>,
}
impl Default for TaskConfig {
@@ -986,6 +991,7 @@ impl Default for TaskConfig {
claude_pre_args: Vec::new(),
enable_permissions: false,
disable_verbose: false,
+ bubblewrap: None,
}
}
}
@@ -1027,7 +1033,8 @@ impl TaskManager {
.with_pre_args(config.claude_pre_args.clone())
.with_permissions_enabled(config.enable_permissions)
.with_verbose_disabled(config.disable_verbose)
- .with_env(config.env_vars.clone()),
+ .with_env(config.env_vars.clone())
+ .with_bubblewrap(config.bubblewrap.clone()),
);
let temp_manager = Arc::new(TempManager::new());
@@ -1150,6 +1157,7 @@ impl TaskManager {
copy_files,
contract_id,
is_supervisor,
+ autonomous_loop,
} => {
tracing::info!(
task_id = %task_id,
@@ -1161,6 +1169,7 @@ impl TaskManager {
depth = depth,
is_orchestrator = is_orchestrator,
is_supervisor = is_supervisor,
+ autonomous_loop = autonomous_loop,
target_repo_path = ?target_repo_path,
completion_action = ?completion_action,
continue_from_task_id = ?continue_from_task_id,
@@ -1173,7 +1182,7 @@ impl TaskManager {
task_id, task_name, plan, repo_url, base_branch, target_branch,
parent_task_id, depth, is_orchestrator, is_supervisor,
target_repo_path, completion_action, continue_from_task_id,
- copy_files, contract_id
+ copy_files, contract_id, autonomous_loop
).await?;
}
DaemonCommand::PauseTask { task_id } => {
@@ -1252,6 +1261,7 @@ impl TaskManager {
None, // continue_from_task_id
None, // copy_files
contract_id,
+ false, // autonomous_loop - supervisors don't use this
).await {
tracing::error!(
task_id = %task_id,
@@ -1421,6 +1431,17 @@ impl TaskManager {
tracing::info!(task_id = %task_id, "Getting task diff");
self.handle_get_task_diff(task_id).await?;
}
+ DaemonCommand::CleanupWorktree {
+ task_id,
+ delete_branch,
+ } => {
+ tracing::info!(
+ task_id = %task_id,
+ delete_branch = delete_branch,
+ "Cleaning up worktree"
+ );
+ self.handle_cleanup_worktree(task_id, delete_branch).await?;
+ }
}
Ok(())
}
@@ -1444,6 +1465,7 @@ impl TaskManager {
continue_from_task_id: Option<Uuid>,
copy_files: Option<Vec<String>>,
contract_id: Option<Uuid>,
+ autonomous_loop: bool,
) -> TaskResult<()> {
tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, is_supervisor = is_supervisor, depth = depth, "=== SPAWN_TASK START ===");
@@ -1496,6 +1518,7 @@ impl TaskManager {
continue_from_task_id,
copy_files: copy_files.clone(),
contract_id,
+ autonomous_loop,
created_at: Instant::now(),
started_at: None,
completed_at: None,
@@ -1519,7 +1542,7 @@ impl TaskManager {
if let Err(e) = inner.run_task(
task_id, task_name, plan, repo_url, base_branch, target_branch,
is_orchestrator, is_supervisor, target_repo_path, completion_action,
- continue_from_task_id, copy_files, contract_id
+ continue_from_task_id, copy_files, contract_id, autonomous_loop
).await {
tracing::error!(task_id = %task_id, error = %e, "Task execution failed");
inner.mark_failed(task_id, &e.to_string()).await;
@@ -2046,6 +2069,76 @@ impl TaskManager {
Ok(())
}
+ /// Handle CleanupWorktree command.
+ ///
+ /// Removes a task's worktree and optionally its branch.
+ /// Used when a contract is completed or deleted to clean up associated task worktrees.
+ async fn handle_cleanup_worktree(
+ &self,
+ task_id: Uuid,
+ delete_branch: bool,
+ ) -> Result<(), DaemonError> {
+ // Try to get the worktree path, but don't fail if not found
+ let worktree_result = self.get_task_worktree_path(task_id).await;
+
+ let (success, message) = match worktree_result {
+ Ok(worktree_path) => {
+ // Remove the worktree
+ match self.worktree_manager.remove_worktree(&worktree_path, delete_branch).await {
+ Ok(()) => {
+ tracing::info!(
+ task_id = %task_id,
+ worktree_path = %worktree_path.display(),
+ delete_branch = delete_branch,
+ "Worktree cleaned up successfully"
+ );
+
+ // Also remove task from in-memory tracking
+ self.tasks.write().await.remove(&task_id);
+ self.task_inputs.write().await.remove(&task_id);
+ self.merge_trackers.write().await.remove(&task_id);
+ self.active_pids.write().await.remove(&task_id);
+
+ (true, format!("Worktree cleaned up: {}", worktree_path.display()))
+ }
+ Err(e) => {
+ tracing::warn!(
+ task_id = %task_id,
+ worktree_path = %worktree_path.display(),
+ error = %e,
+ "Failed to remove worktree"
+ );
+ (false, format!("Failed to remove worktree: {}", e))
+ }
+ }
+ }
+ Err(_) => {
+ // Worktree not found - this is OK, it may have already been cleaned up
+ tracing::debug!(
+ task_id = %task_id,
+ "No worktree found for task, may have already been cleaned up"
+ );
+
+ // Still remove from in-memory tracking
+ self.tasks.write().await.remove(&task_id);
+ self.task_inputs.write().await.remove(&task_id);
+ self.merge_trackers.write().await.remove(&task_id);
+ self.active_pids.write().await.remove(&task_id);
+
+ (true, "No worktree found, task tracking cleaned up".to_string())
+ }
+ };
+
+ // Send result back to server
+ let msg = DaemonMessage::CleanupWorktreeResult {
+ task_id,
+ success,
+ message,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ Ok(())
+ }
+
/// Handle ReadRepoFile command.
///
/// Reads a file from a repository on the daemon's filesystem and sends
@@ -2436,6 +2529,7 @@ impl TaskManagerInner {
continue_from_task_id: Option<Uuid>,
copy_files: Option<Vec<String>>,
contract_id: Option<Uuid>,
+ autonomous_loop: bool,
) -> Result<(), DaemonError> {
tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, is_supervisor = is_supervisor, "=== RUN_TASK START ===");
@@ -2908,6 +3002,9 @@ impl TaskManagerInner {
);
let _ = self.ws_tx.send(msg).await;
+ // Clone extra_env for use in autonomous loop iterations
+ let extra_env_for_loop = extra_env.clone();
+
tracing::debug!(task_id = %task_id, has_system_prompt = system_prompt.is_some(), "Calling process_manager.spawn()...");
let mut process = self.process_manager
.spawn_with_system_prompt(&working_dir, &full_plan, extra_env, system_prompt.as_deref())
@@ -2934,7 +3031,7 @@ impl TaskManagerInner {
// Get stdin handle for input forwarding and completion signaling
let stdin_handle = process.stdin_handle();
- let stdin_handle_for_completion = stdin_handle.clone();
+ let mut stdin_handle_for_completion = stdin_handle.clone();
tracing::info!(task_id = %task_id, "Setting up stdin forwarder for task input (JSON protocol)");
tokio::spawn(async move {
@@ -2998,142 +3095,311 @@ impl TaskManagerInner {
let daemon_hostname = hostname::get().ok().and_then(|h| h.into_string().ok());
let mut auth_error_handled = false;
- let mut output_count = 0u64;
- let mut output_bytes = 0usize;
- let startup_timeout = tokio::time::Duration::from_secs(30);
- let mut startup_check = tokio::time::interval(tokio::time::Duration::from_secs(5));
- startup_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
- let startup_deadline = tokio::time::Instant::now() + startup_timeout;
+ // For autonomous loop mode: track accumulated output for COMPLETION_GATE detection
+ let mut accumulated_output = String::new();
+ let mut circuit_breaker = CircuitBreaker::new();
+ let mut iteration_count = 0u32;
+ let mut final_exit_code: i64 = -1; // Track the final exit code across iterations
- loop {
- tokio::select! {
- maybe_line = process.next_output() => {
- match maybe_line {
- Some(line) => {
- output_count += 1;
- output_bytes += line.content.len();
-
- if output_count == 1 {
- tracing::info!(task_id = %task_id, "Received first output line from Claude");
- }
- if output_count % 100 == 0 {
- tracing::debug!(task_id = %task_id, output_count = output_count, output_bytes = output_bytes, "Output progress");
- }
+ // Autonomous loop: we may run multiple iterations
+ 'autonomous_loop: loop {
+ iteration_count += 1;
- // Log output details for debugging
- tracing::trace!(
- task_id = %task_id,
- line_num = output_count,
- content_len = line.content.len(),
- is_stdout = line.is_stdout,
- json_type = ?line.json_type,
- "Forwarding output to WebSocket"
- );
+ if autonomous_loop && iteration_count > 1 {
+ tracing::info!(
+ task_id = %task_id,
+ iteration = iteration_count,
+ "Starting autonomous loop iteration"
+ );
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("\n[Autonomous Loop] Starting iteration {} (--continue mode)\n", iteration_count),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ // For subsequent iterations, spawn with --continue flag
+ let continuation_prompt = "Continue working on the task. Review your previous output and progress. When you are completely done, output a COMPLETION_GATE block with ready: true.";
+
+ process = self.process_manager
+ .spawn_continue(&working_dir, continuation_prompt, extra_env_for_loop.clone(), system_prompt.as_deref())
+ .await
+ .map_err(|e| {
+ tracing::error!(task_id = %task_id, error = %e, "Failed to spawn Claude process for continuation");
+ DaemonError::Task(TaskError::SetupFailed(e.to_string()))
+ })?;
+
+ // Register the new process PID
+ if let Some(pid) = process.id() {
+ self.active_pids.write().await.insert(task_id, pid);
+ tracing::info!(task_id = %task_id, pid = pid, iteration = iteration_count, "Claude continue process spawned");
+ }
+
+ // Reset stdin handle for the new process
+ stdin_handle_for_completion = process.stdin_handle();
+ }
+
+ // Clear output for this iteration (we'll check for COMPLETION_GATE in the new output)
+ let mut iteration_output = String::new();
+
+ let mut output_count = 0u64;
+ let mut output_bytes = 0usize;
+ let startup_timeout = tokio::time::Duration::from_secs(30);
+ let mut startup_check = tokio::time::interval(tokio::time::Duration::from_secs(5));
+ startup_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ let startup_deadline = tokio::time::Instant::now() + startup_timeout;
- // Check if this is a "result" message indicating task completion
- // With --input-format=stream-json, Claude waits for more input after completion
- // We close stdin to signal EOF and let the process exit
- if line.json_type.as_deref() == Some("result") {
- tracing::info!(task_id = %task_id, "Received result message, closing stdin to signal completion");
- let mut stdin_guard = stdin_handle_for_completion.lock().await;
- if let Some(mut stdin) = stdin_guard.take() {
- let _ = stdin.shutdown().await;
+ loop {
+ tokio::select! {
+ maybe_line = process.next_output() => {
+ match maybe_line {
+ Some(line) => {
+ output_count += 1;
+ output_bytes += line.content.len();
+
+ // Accumulate output for COMPLETION_GATE detection in autonomous loop mode
+ if autonomous_loop {
+ iteration_output.push_str(&line.content);
+ iteration_output.push('\n');
}
- }
- // Check for OAuth auth error before sending output
- let content_for_auth_check = line.content.clone();
- let json_type_for_auth_check = line.json_type.clone();
- let is_stdout_for_auth_check = line.is_stdout;
+ if output_count == 1 {
+ tracing::info!(task_id = %task_id, "Received first output line from Claude");
+ }
+ if output_count % 100 == 0 {
+ tracing::debug!(task_id = %task_id, output_count = output_count, output_bytes = output_bytes, "Output progress");
+ }
- let msg = DaemonMessage::task_output(task_id, line.content, false);
- if ws_tx.send(msg).await.is_err() {
- tracing::warn!(task_id = %task_id, "Failed to send output, channel closed");
- break;
- }
+ // Log output details for debugging
+ tracing::trace!(
+ task_id = %task_id,
+ line_num = output_count,
+ content_len = line.content.len(),
+ is_stdout = line.is_stdout,
+ json_type = ?line.json_type,
+ "Forwarding output to WebSocket"
+ );
- // Detect OAuth token expiration and trigger remote login flow
- if !auth_error_handled && is_oauth_auth_error(&content_for_auth_check, json_type_for_auth_check.as_deref(), is_stdout_for_auth_check) {
- auth_error_handled = true;
- tracing::warn!(task_id = %task_id, "OAuth authentication error detected, initiating remote login flow");
-
- // Spawn claude setup-token to get login URL
- if let Some(login_url) = get_oauth_login_url(&claude_command).await {
- tracing::info!(task_id = %task_id, login_url = %login_url, "Got OAuth login URL");
- let auth_msg = DaemonMessage::AuthenticationRequired {
- task_id: Some(task_id),
- login_url,
- hostname: daemon_hostname.clone(),
- };
- if ws_tx.send(auth_msg).await.is_err() {
- tracing::warn!(task_id = %task_id, "Failed to send auth required message");
+ // Check if this is a "result" message indicating task completion
+ // With --input-format=stream-json, Claude waits for more input after completion
+ // We close stdin to signal EOF and let the process exit
+ if line.json_type.as_deref() == Some("result") {
+ tracing::info!(task_id = %task_id, "Received result message, closing stdin to signal completion");
+ let mut stdin_guard = stdin_handle_for_completion.lock().await;
+ if let Some(mut stdin) = stdin_guard.take() {
+ let _ = stdin.shutdown().await;
+ }
+ }
+
+ // Check for OAuth auth error before sending output
+ let content_for_auth_check = line.content.clone();
+ let json_type_for_auth_check = line.json_type.clone();
+ let is_stdout_for_auth_check = line.is_stdout;
+
+ let msg = DaemonMessage::task_output(task_id, line.content, false);
+ if ws_tx.send(msg).await.is_err() {
+ tracing::warn!(task_id = %task_id, "Failed to send output, channel closed");
+ break;
+ }
+
+ // Detect OAuth token expiration and trigger remote login flow
+ if !auth_error_handled && is_oauth_auth_error(&content_for_auth_check, json_type_for_auth_check.as_deref(), is_stdout_for_auth_check) {
+ auth_error_handled = true;
+ tracing::warn!(task_id = %task_id, "OAuth authentication error detected, initiating remote login flow");
+
+ // Spawn claude setup-token to get login URL
+ if let Some(login_url) = get_oauth_login_url(&claude_command).await {
+ tracing::info!(task_id = %task_id, login_url = %login_url, "Got OAuth login URL");
+ let auth_msg = DaemonMessage::AuthenticationRequired {
+ task_id: Some(task_id),
+ login_url,
+ hostname: daemon_hostname.clone(),
+ };
+ if ws_tx.send(auth_msg).await.is_err() {
+ tracing::warn!(task_id = %task_id, "Failed to send auth required message");
+ }
+ } else {
+ tracing::error!(task_id = %task_id, "Failed to get OAuth login URL from setup-token");
+ let fallback_msg = DaemonMessage::task_output(
+ task_id,
+ format!("Authentication required on daemon{}. Please run 'claude /login' on the daemon machine.\n",
+ daemon_hostname.as_ref().map(|h| format!(" ({})", h)).unwrap_or_default()),
+ false,
+ );
+ let _ = ws_tx.send(fallback_msg).await;
}
- } else {
- tracing::error!(task_id = %task_id, "Failed to get OAuth login URL from setup-token");
- let fallback_msg = DaemonMessage::task_output(
- task_id,
- format!("Authentication required on daemon{}. Please run 'claude /login' on the daemon machine.\n",
- daemon_hostname.as_ref().map(|h| format!(" ({})", h)).unwrap_or_default()),
- false,
- );
- let _ = ws_tx.send(fallback_msg).await;
}
}
- }
- None => {
- tracing::info!(task_id = %task_id, output_count = output_count, output_bytes = output_bytes, "Output stream ended");
- break;
+ None => {
+ tracing::info!(task_id = %task_id, output_count = output_count, output_bytes = output_bytes, "Output stream ended");
+ break;
+ }
}
}
- }
- _ = startup_check.tick(), if output_count == 0 => {
- // Check if process is still alive
- match process.try_wait() {
- Ok(Some(exit_code)) => {
- tracing::error!(task_id = %task_id, exit_code = exit_code, "Claude process exited before producing output!");
- let msg = DaemonMessage::task_output(
- task_id,
- format!("Error: Claude process exited unexpectedly with code {}\n", exit_code),
- false,
- );
- let _ = ws_tx.send(msg).await;
- break;
- }
- Ok(None) => {
- // Still running but no output
- if tokio::time::Instant::now() > startup_deadline {
- tracing::warn!(task_id = %task_id, "Claude process not producing output after 30s - may be stuck");
+ _ = startup_check.tick(), if output_count == 0 => {
+ // Check if process is still alive
+ match process.try_wait() {
+ Ok(Some(exit_code)) => {
+ tracing::error!(task_id = %task_id, exit_code = exit_code, "Claude process exited before producing output!");
let msg = DaemonMessage::task_output(
task_id,
- "Warning: Claude Code is taking longer than expected to start. It may be waiting for authentication or network access.\n".to_string(),
+ format!("Error: Claude process exited unexpectedly with code {}\n", exit_code),
false,
);
let _ = ws_tx.send(msg).await;
- } else {
- tracing::debug!(task_id = %task_id, "Claude process still running, waiting for output...");
+ break;
+ }
+ Ok(None) => {
+ // Still running but no output
+ if tokio::time::Instant::now() > startup_deadline {
+ tracing::warn!(task_id = %task_id, "Claude process not producing output after 30s - may be stuck");
+ let msg = DaemonMessage::task_output(
+ task_id,
+ "Warning: Claude Code is taking longer than expected to start. It may be waiting for authentication or network access.\n".to_string(),
+ false,
+ );
+ let _ = ws_tx.send(msg).await;
+ } else {
+ tracing::debug!(task_id = %task_id, "Claude process still running, waiting for output...");
+ }
+ }
+ Err(e) => {
+ tracing::error!(task_id = %task_id, error = %e, "Failed to check Claude process status");
}
- }
- Err(e) => {
- tracing::error!(task_id = %task_id, error = %e, "Failed to check Claude process status");
}
}
}
}
- }
- // Wait for process to exit
- let exit_code = process.wait().await.unwrap_or(-1);
+ // Wait for process to exit
+ let exit_code = process.wait().await.unwrap_or(-1);
+ final_exit_code = exit_code; // Store for use after the loop
+
+ // Unregister the process PID (process has exited)
+ self.active_pids.write().await.remove(&task_id);
+ tracing::debug!(task_id = %task_id, "Unregistered process PID");
+
+ // Clean up input channel for this task
+ self.task_inputs.write().await.remove(&task_id);
+ tracing::debug!(task_id = %task_id, "Removed task input channel");
- // Unregister the process PID (process has exited)
- self.active_pids.write().await.remove(&task_id);
- tracing::debug!(task_id = %task_id, "Unregistered process PID");
+ // Accumulate this iteration's output
+ accumulated_output.push_str(&iteration_output);
- // Clean up input channel for this task
- self.task_inputs.write().await.remove(&task_id);
- tracing::debug!(task_id = %task_id, "Removed task input channel");
+ // === AUTONOMOUS LOOP LOGIC ===
+ // Check if we should continue or complete
+ if autonomous_loop && exit_code == 0 {
+ // Check for COMPLETION_GATE in the output
+ let completion_gate = CompletionGate::parse_last(&iteration_output);
+
+ match completion_gate {
+ Some(gate) if gate.ready => {
+ tracing::info!(
+ task_id = %task_id,
+ iteration = iteration_count,
+ reason = ?gate.reason,
+ "COMPLETION_GATE ready=true detected, task complete"
+ );
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("\n[Autonomous Loop] Task completed after {} iteration(s). Reason: {}\n",
+ iteration_count,
+ gate.reason.unwrap_or_else(|| "Task complete".to_string())
+ ),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+ break 'autonomous_loop;
+ }
+ Some(gate) => {
+ // COMPLETION_GATE found but not ready
+ tracing::info!(
+ task_id = %task_id,
+ iteration = iteration_count,
+ reason = ?gate.reason,
+ blockers = ?gate.blockers,
+ "COMPLETION_GATE ready=false, will continue"
+ );
+
+ // Check circuit breaker
+ // For now, we consider output_bytes > 0 as "progress"
+ let had_progress = output_bytes > 0;
+ let error = gate.blockers.as_ref().and_then(|b| b.first()).map(|s| s.as_str());
+
+ if !circuit_breaker.record_iteration(had_progress, error) {
+ // Circuit breaker tripped
+ tracing::warn!(
+ task_id = %task_id,
+ reason = ?circuit_breaker.open_reason,
+ "Circuit breaker tripped, stopping autonomous loop"
+ );
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("\n[Autonomous Loop] Circuit breaker tripped: {}\n",
+ circuit_breaker.open_reason.as_deref().unwrap_or("Unknown reason")
+ ),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+ break 'autonomous_loop;
+ }
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("\n[Autonomous Loop] COMPLETION_GATE ready=false. Reason: {}. Restarting...\n",
+ gate.reason.unwrap_or_else(|| "Not complete".to_string())
+ ),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ // Continue to next iteration
+ continue 'autonomous_loop;
+ }
+ None => {
+ // No COMPLETION_GATE found - check circuit breaker and continue
+ tracing::info!(
+ task_id = %task_id,
+ iteration = iteration_count,
+ "No COMPLETION_GATE found, will restart with continuation prompt"
+ );
+
+ let had_progress = output_bytes > 0;
+ if !circuit_breaker.record_iteration(had_progress, None) {
+ tracing::warn!(
+ task_id = %task_id,
+ reason = ?circuit_breaker.open_reason,
+ "Circuit breaker tripped (no COMPLETION_GATE), stopping"
+ );
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("\n[Autonomous Loop] Circuit breaker tripped: {}\n",
+ circuit_breaker.open_reason.as_deref().unwrap_or("Unknown reason")
+ ),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+ break 'autonomous_loop;
+ }
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ "\n[Autonomous Loop] No COMPLETION_GATE found. Restarting with --continue...\n".to_string(),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ continue 'autonomous_loop;
+ }
+ }
+ } else {
+ // Not in autonomous loop mode or process failed - exit normally
+ break 'autonomous_loop;
+ }
+ } // end 'autonomous_loop
// Update state based on exit code
- let success = exit_code == 0;
+ let success = final_exit_code == 0;
let new_state = if success {
TaskState::Completed
} else {
@@ -3142,7 +3408,7 @@ impl TaskManagerInner {
tracing::info!(
task_id = %task_id,
- exit_code = exit_code,
+ exit_code = final_exit_code,
success = success,
new_state = ?new_state,
"Claude process exited, updating task state"
@@ -3154,7 +3420,7 @@ impl TaskManagerInner {
task.state = new_state;
task.completed_at = Some(Instant::now());
if !success {
- task.error = Some(format!("Process exited with code {}", exit_code));
+ task.error = Some(format!("Process exited with code {}", final_exit_code));
}
}
}
@@ -3196,7 +3462,7 @@ impl TaskManagerInner {
if is_supervisor {
tracing::info!(
task_id = %task_id,
- exit_code = exit_code,
+ exit_code = final_exit_code,
"Supervisor Claude process exited - NOT marking as complete"
);
// Update local state to reflect it's paused/waiting for input
@@ -3218,7 +3484,7 @@ impl TaskManagerInner {
let error = if success {
None
} else {
- Some(format!("Exit code: {}", exit_code))
+ Some(format!("Exit code: {}", final_exit_code))
};
tracing::info!(task_id = %task_id, success = success, "Notifying server of task completion");
let msg = DaemonMessage::task_complete(task_id, success, error);
diff --git a/makima/src/daemon/task/mod.rs b/makima/src/daemon/task/mod.rs
index 29c261e..3830e1d 100644
--- a/makima/src/daemon/task/mod.rs
+++ b/makima/src/daemon/task/mod.rs
@@ -1,7 +1,9 @@
//! Task management and execution.
+pub mod completion_gate;
pub mod manager;
pub mod state;
+pub use completion_gate::CompletionGate;
pub use manager::{ManagedTask, TaskConfig, TaskManager};
pub use state::TaskState;
diff --git a/makima/src/daemon/task/state.rs b/makima/src/daemon/task/state.rs
index ca5fc01..7b59b62 100644
--- a/makima/src/daemon/task/state.rs
+++ b/makima/src/daemon/task/state.rs
@@ -124,7 +124,9 @@ impl Default for TaskState {
#[cfg(test)]
mod tests {
+ #[allow(unused_imports)]
use crate::daemon::*;
+ use super::TaskState;
#[test]
fn test_valid_transitions() {
diff --git a/makima/src/daemon/ws/protocol.rs b/makima/src/daemon/ws/protocol.rs
index e86a577..714c0f9 100644
--- a/makima/src/daemon/ws/protocol.rs
+++ b/makima/src/daemon/ws/protocol.rs
@@ -250,6 +250,14 @@ pub enum DaemonMessage {
diff: Option<String>,
error: Option<String>,
},
+
+ /// Response to CleanupWorktree command.
+ CleanupWorktreeResult {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ message: String,
+ },
}
/// Information about a branch (used in BranchList message).
@@ -323,6 +331,11 @@ pub enum DaemonCommand {
/// Whether this task is a supervisor (long-running contract orchestrator).
#[serde(rename = "isSupervisor", default)]
is_supervisor: bool,
+ /// Whether to run in autonomous loop mode.
+ /// When enabled, task will automatically restart with --continue if it exits
+ /// without a COMPLETION_GATE indicating ready: true.
+ #[serde(rename = "autonomousLoop", default)]
+ autonomous_loop: bool,
},
/// Pause a running task.
@@ -530,6 +543,15 @@ pub enum DaemonCommand {
task_id: Uuid,
},
+ /// Clean up a task's worktree (used when contract is completed/deleted).
+ CleanupWorktree {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Whether to delete the associated branch.
+ #[serde(rename = "deleteBranch")]
+ delete_branch: bool,
+ },
+
/// Error response.
Error {
code: String,
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 8ab3a10..40d4109 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -1194,6 +1194,11 @@ pub struct Contract {
/// The long-running supervisor task that orchestrates this contract
#[serde(skip_serializing_if = "Option::is_none")]
pub supervisor_task_id: Option<Uuid>,
+ /// Whether tasks for this contract should run in autonomous loop mode.
+ /// When enabled, tasks will automatically restart with --continue if they exit
+ /// without a COMPLETION_GATE indicating ready: true.
+ #[serde(default)]
+ pub autonomous_loop: bool,
pub version: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -1314,6 +1319,11 @@ pub struct CreateContractRequest {
/// - specification: defaults to "research"
#[serde(default)]
pub initial_phase: Option<String>,
+ /// Enable autonomous loop mode for tasks in this contract.
+ /// When enabled, tasks automatically restart with --continue if they exit
+ /// without a COMPLETION_GATE indicating ready: true.
+ #[serde(default)]
+ pub autonomous_loop: Option<bool>,
}
/// Request payload for updating a contract
@@ -1327,6 +1337,9 @@ pub struct UpdateContractRequest {
/// Supervisor task ID for contract orchestration
#[serde(skip_serializing_if = "Option::is_none")]
pub supervisor_task_id: Option<Uuid>,
+ /// Enable or disable autonomous loop mode for tasks in this contract.
+ #[serde(default)]
+ pub autonomous_loop: Option<bool>,
/// Version for optimistic locking
pub version: Option<i32>,
}
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 0e85be1..92b2048 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -2052,10 +2052,12 @@ pub async fn create_contract_for_owner(
)));
}
+ let autonomous_loop = req.autonomous_loop.unwrap_or(false);
+
sqlx::query_as::<_, Contract>(
r#"
- INSERT INTO contracts (owner_id, name, description, contract_type, phase)
- VALUES ($1, $2, $3, $4, $5)
+ INSERT INTO contracts (owner_id, name, description, contract_type, phase, autonomous_loop)
+ VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
"#,
)
@@ -2064,6 +2066,7 @@ pub async fn create_contract_for_owner(
.bind(&req.description)
.bind(contract_type)
.bind(phase)
+ .bind(autonomous_loop)
.fetch_one(pool)
.await
}
@@ -2162,14 +2165,15 @@ pub async fn update_contract_for_owner(
let phase = req.phase.unwrap_or(existing.phase);
let status = req.status.unwrap_or(existing.status);
let supervisor_task_id = req.supervisor_task_id.or(existing.supervisor_task_id);
+ let autonomous_loop = req.autonomous_loop.unwrap_or(existing.autonomous_loop);
let result = if req.version.is_some() {
sqlx::query_as::<_, Contract>(
r#"
UPDATE contracts
SET name = $3, description = $4, phase = $5, status = $6,
- supervisor_task_id = $7, version = version + 1, updated_at = NOW()
- WHERE id = $1 AND owner_id = $2 AND version = $8
+ supervisor_task_id = $7, autonomous_loop = $8, version = version + 1, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2 AND version = $9
RETURNING *
"#,
)
@@ -2180,6 +2184,7 @@ pub async fn update_contract_for_owner(
.bind(&phase)
.bind(&status)
.bind(supervisor_task_id)
+ .bind(autonomous_loop)
.bind(req.version.unwrap())
.fetch_optional(pool)
.await?
@@ -2188,7 +2193,7 @@ pub async fn update_contract_for_owner(
r#"
UPDATE contracts
SET name = $3, description = $4, phase = $5, status = $6,
- supervisor_task_id = $7, version = version + 1, updated_at = NOW()
+ supervisor_task_id = $7, autonomous_loop = $8, version = version + 1, updated_at = NOW()
WHERE id = $1 AND owner_id = $2
RETURNING *
"#,
@@ -2200,6 +2205,7 @@ pub async fn update_contract_for_owner(
.bind(&phase)
.bind(&status)
.bind(supervisor_task_id)
+ .bind(autonomous_loop)
.fetch_optional(pool)
.await?
};
@@ -2591,6 +2597,32 @@ pub async fn list_tasks_in_contract(
.await
}
+/// Minimal task info for worktree cleanup operations.
+#[derive(Debug, Clone, sqlx::FromRow)]
+pub struct TaskWorktreeInfo {
+ pub id: Uuid,
+ pub daemon_id: Option<Uuid>,
+ pub overlay_path: Option<String>,
+}
+
+/// List tasks in a contract with their daemon/worktree info.
+/// Used for cleaning up worktrees when a contract is completed or deleted.
+pub async fn list_contract_tasks_with_worktree_info(
+ pool: &PgPool,
+ contract_id: Uuid,
+) -> Result<Vec<TaskWorktreeInfo>, sqlx::Error> {
+ sqlx::query_as::<_, TaskWorktreeInfo>(
+ r#"
+ SELECT id, daemon_id, overlay_path
+ FROM tasks
+ WHERE contract_id = $1 AND (daemon_id IS NOT NULL OR overlay_path IS NOT NULL)
+ "#,
+ )
+ .bind(contract_id)
+ .fetch_all(pool)
+ .await
+}
+
// =============================================================================
// Contract Events
// =============================================================================
diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs
index e2bd10e..101b257 100644
--- a/makima/src/server/handlers/contract_chat.rs
+++ b/makima/src/server/handlers/contract_chat.rs
@@ -2376,6 +2376,7 @@ async fn handle_contract_request(
description: contract_description,
contract_type: Some("specification".to_string()),
initial_phase: Some("research".to_string()),
+ autonomous_loop: None,
};
let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await {
diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs
index 3ce29e1..09f78e6 100644
--- a/makima/src/server/handlers/contracts.rs
+++ b/makima/src/server/handlers/contracts.rs
@@ -425,7 +425,7 @@ pub async fn update_contract(
match repository::update_contract_for_owner(pool, id, auth.owner_id, req).await {
Ok(Some(contract)) => {
- // If contract is completed, stop the supervisor task
+ // If contract is completed, stop the supervisor task and clean up worktrees
if contract.status == "completed" {
if let Some(supervisor_task_id) = contract.supervisor_task_id {
// Get the supervisor task to find its daemon
@@ -456,6 +456,14 @@ pub async fn update_contract(
}
}
}
+
+ // Clean up all task worktrees for this contract
+ let pool_clone = pool.clone();
+ let state_clone = state.clone();
+ let contract_id = id;
+ tokio::spawn(async move {
+ cleanup_contract_worktrees(&pool_clone, &state_clone, contract_id).await;
+ });
}
// Get summary with counts
@@ -548,6 +556,30 @@ pub async fn delete_contract(
.into_response();
};
+ // First, verify contract exists and belongs to owner
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ // Clean up all task worktrees BEFORE deleting the contract
+ // (because CASCADE delete will remove tasks from DB)
+ cleanup_contract_worktrees(pool, &state, id).await;
+
match repository::delete_contract_for_owner(pool, id, auth.owner_id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (
@@ -1318,3 +1350,85 @@ pub async fn get_events(
}
}
}
+
+// =============================================================================
+// Internal Helper Functions
+// =============================================================================
+
+/// Clean up all worktrees for tasks in a contract.
+///
+/// This is called when a contract is completed or deleted to remove
+/// all associated task worktrees from connected daemons.
+async fn cleanup_contract_worktrees(
+ pool: &sqlx::PgPool,
+ state: &SharedState,
+ contract_id: Uuid,
+) {
+ tracing::info!(
+ contract_id = %contract_id,
+ "Cleaning up worktrees for contract tasks"
+ );
+
+ // Get all tasks with worktree info for this contract
+ let tasks = match repository::list_contract_tasks_with_worktree_info(pool, contract_id).await {
+ Ok(tasks) => tasks,
+ Err(e) => {
+ tracing::error!(
+ contract_id = %contract_id,
+ error = %e,
+ "Failed to list tasks for worktree cleanup"
+ );
+ return;
+ }
+ };
+
+ if tasks.is_empty() {
+ tracing::debug!(
+ contract_id = %contract_id,
+ "No tasks with worktrees to clean up"
+ );
+ return;
+ }
+
+ tracing::info!(
+ contract_id = %contract_id,
+ task_count = tasks.len(),
+ "Found tasks with worktrees to clean up"
+ );
+
+ // Send cleanup command to each task's daemon
+ for task in tasks {
+ if let Some(daemon_id) = task.daemon_id {
+ let cmd = crate::server::state::DaemonCommand::CleanupWorktree {
+ task_id: task.id,
+ delete_branch: true, // Delete the branch when contract is done
+ };
+
+ match state.send_daemon_command(daemon_id, cmd).await {
+ Ok(()) => {
+ tracing::info!(
+ task_id = %task.id,
+ daemon_id = %daemon_id,
+ contract_id = %contract_id,
+ "Sent worktree cleanup command"
+ );
+ }
+ Err(e) => {
+ tracing::warn!(
+ task_id = %task.id,
+ daemon_id = %daemon_id,
+ contract_id = %contract_id,
+ error = %e,
+ "Failed to send worktree cleanup command (daemon may be offline)"
+ );
+ }
+ }
+ } else {
+ tracing::debug!(
+ task_id = %task.id,
+ contract_id = %contract_id,
+ "Task has no daemon assigned, skipping worktree cleanup"
+ );
+ }
+ }
+}
diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs
index d0fa4d1..3add89f 100644
--- a/makima/src/server/handlers/mesh_supervisor.rs
+++ b/makima/src/server/handlers/mesh_supervisor.rs
@@ -18,7 +18,7 @@ use crate::db::repository;
use crate::server::auth::Authenticated;
use crate::server::handlers::mesh::{extract_auth, AuthSource};
use crate::server::messages::ApiError;
-use crate::server::state::{DaemonCommand, SharedState};
+use crate::server::state::{DaemonCommand, SharedState, TaskOutputNotification};
// =============================================================================
// Request/Response Types
@@ -1311,6 +1311,43 @@ pub async fn ask_question(
request.context.clone(),
);
+ // Broadcast question as task output entry for the task's chat
+ let question_data = serde_json::json!({
+ "question_id": question_id.to_string(),
+ "choices": request.choices,
+ "context": request.context,
+ });
+ state.broadcast_task_output(TaskOutputNotification {
+ task_id: supervisor_id,
+ owner_id: Some(owner_id),
+ message_type: "supervisor_question".to_string(),
+ content: request.question.clone(),
+ tool_name: None,
+ tool_input: Some(question_data.clone()),
+ is_error: None,
+ cost_usd: None,
+ duration_ms: None,
+ is_partial: false,
+ });
+
+ // Persist to database so it appears when reloading the page
+ // Use event_type "output" with messageType "supervisor_question" to match TaskOutputEntry format
+ if let Some(pool) = state.db_pool.as_ref() {
+ let event_data = serde_json::json!({
+ "messageType": "supervisor_question",
+ "content": request.question,
+ "toolInput": question_data,
+ });
+ let _ = repository::create_task_event(
+ pool,
+ supervisor_id,
+ "output",
+ None,
+ None,
+ Some(event_data),
+ ).await;
+ }
+
// Poll for response with timeout
let timeout_duration = std::time::Duration::from_secs(request.timeout_seconds.max(1) as u64);
let start = std::time::Instant::now();
diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs
index 2c38eea..275905e 100644
--- a/makima/src/server/handlers/transcript_analysis.rs
+++ b/makima/src/server/handlers/transcript_analysis.rs
@@ -276,6 +276,7 @@ pub async fn create_contract_from_analysis(
description: contract_description,
contract_type: Some("specification".to_string()),
initial_phase: Some("research".to_string()),
+ autonomous_loop: None,
};
let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await {
diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs
index 495fc15..2a45d88 100644
--- a/makima/src/server/state.rs
+++ b/makima/src/server/state.rs
@@ -396,6 +396,15 @@ pub enum DaemonCommand {
task_id: Uuid,
},
+ /// Clean up a task's worktree (used when contract is completed/deleted)
+ CleanupWorktree {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Whether to delete the associated branch
+ #[serde(rename = "deleteBranch")]
+ delete_branch: bool,
+ },
+
/// Error response
Error { code: String, message: String },
}