Skill: rajeshhuria/ollama-task-orchestrator (version 1.0.0)

# Ollama Task Orchestrator — Skill Specification ## Overview Manage Ollama task queue and code generation on a remote worker machine via SSH. Provides queue health checks and task execution with exclusivity locking. ## Commands ### `ollama status [clean] [--force] [--kill-ollama]` Check queue status and Ollama server health. - No args: show status only - `clean`: remove stale locks (prompts if Ollama is busy) - `clean --force`: remove stale locks without prompting - `clean --kill-ollama`: stop and restart Ollama server to cancel active generation ### `ollama run <task>` Execute a task on the worker machine. Available tasks: | Task | Usage | Description | |---|---|---| | `ping` | `ollama run ping` | Check runner is alive | | `codegen` | `ollama run codegen <instruction>` | Generate code via Ollama | | `write` | `ollama run write <file> <instruction>` | Generate and write code to file | | `test` | `ollama run test [suite]` | Run project tests | | `exec` | `ollama run exec <command>` | Run arbitrary shell command | | `list-projects` | `ollama run list-projects` | List available projects | ## Configuration | Variable | Default | Description | |---|---|---| | `OLLAMA_WORKER_HOST` | `worker-mac` | SSH host alias | | `OLLAMA_RUNNER_PATH` | `~/worker/runner` | Runner scripts path on worker | | `WORKER_ROOT` | `$HOME/worker` | Worker root directory | | `PROJECTS_DIR` | `$WORKER_ROOT/projects` | Projects directory | | `DEFAULT_PROJECT` | _(none)_ | Default project for context | | `OLLAMA_MODEL` | `qwen2.5-coder:32b` | Ollama model | | `OLLAMA_URL` | `http://localhost:11434/api/generate` | Ollama API endpoint | ## Files ``` skill.py OpenClaw skill entry point runner/ queue_status.sh Queue and server health checker run_task.sh Task executor with Ollama integration README.md Setup and usage guide SKILL.md This file ```

path: skill.py
content: #!/usr/bin/env python3
"""
Ollama Task Orchestrator — OpenClaw skill
Manages Ollama queue and task execution on a remote worker Mac via SSH.

Configuration (environment variables or defaults):
  OLLAMA_WORKER_HOST   SSH host alias for the worker Mac (default: worker-mac)
  OLLAMA_RUNNER_PATH   Path to the runner directory on the worker Mac
                       (default: ~/worker/runner)
"""

import os
import subprocess
import threading
import shlex

WORKER_HOST = os.environ.get("OLLAMA_WORKER_HOST", "worker-mac")
RUNNER_PATH = os.environ.get("OLLAMA_RUNNER_PATH", "~/worker/runner")


class OllamaTaskOrchestrator:
    def __init__(self, worker_host: str = WORKER_HOST, runner_path: str = RUNNER_PATH):
        self.worker_host = worker_host
        self.runner_path = runner_path
        self.lock = threading.Lock()

    def _ssh(self, command):
        """Run a command on the worker Mac via SSH."""
        if isinstance(command, str):
            remote_command = command
        else:
            remote_command = shlex.join(command)
        result = subprocess.run(
            ["ssh", self.worker_host, remote_command],
            capture_output=True,
            text=True,
        )
        return result.stdout.strip(), result.stderr.strip(), result.returncode

    def queue_status(self, args: str = "") -> str:
        """Check queue status and Ollama server health."""
        cmd = [f"{self.runner_path}/queue_status.sh"]
        if args:
            cmd.extend(shlex.split(args))
        stdout, stderr, code = self._ssh(cmd)
        if code != 0:
            return f"Error checking queue status: {stderr}"
        return stdout

    def run_task(self, task_command: str) -> str:
        """Run a task on the worker Mac with exclusivity locking."""
        with self.lock:
            cmd = [f"{self.runner_path}/run_task.sh", *shlex.split(task_command)]
            stdout, stderr, code = self._ssh(cmd)
            if code != 0:
                return f"Error running task: {stderr}"
            return stdout

    def handle(self, command_line: str) -> str:
        """Parse and dispatch CLI/chat commands."""
        parts = command_line.strip().split(maxsplit=2)
        if not parts:
            return "No command provided."

        cmd = parts[0].lower()
        if cmd != "ollama":
            return "Unknown command. Use: ollama status | ollama run <task>"

        if len(parts) < 2:
            return "Usage: ollama <status|run> [args]"

        subcmd = parts[1].lower()

        if subcmd == "status":
            extra = parts[2] if len(parts) > 2 else ""
            return self.queue_status(extra)

        elif subcmd == "run":
            if len(parts) < 3:
                return "Usage: ollama run <task_command>"
            return self.run_task(parts[2])

        else:
            return f"Unknown subcommand: {subcmd}. Use: status | run"


if __name__ == "__main__":
    import sys

    orchestrator = OllamaTaskOrchestrator()
    if len(sys.argv) < 2:
        print("Usage: python skill.py <status|run> [args]")
        sys.exit(1)
    output = orchestrator.handle("ollama " + " ".join(sys.argv[1:]))
    print(output)


path: README-claude-code.md
content: # Using with Claude Code and Codex

The `runner/` scripts in this repo are plain bash — no OpenClaw required. You can call them directly from Claude Code or Codex via SSH.

---

## Claude Code

### Setup

Make sure Claude Code can SSH into your worker machine. Add an entry to `~/.ssh/config`:

```
Host <your-worker-host-alias>
  HostName <your-worker-ip-or-hostname>
  User <your-username>
  IdentityFile ~/.ssh/id_ed25519
```

### Tell Claude Code about the runner

Add a `CLAUDE.md` file to your project root so Claude Code knows the scripts exist:

```markdown
## Ollama Runner (worker machine)

For code generation tasks that should run locally on the worker machine, use:

- Check Ollama status:
  `ssh <your-worker-host-alias> '~/worker/runner/queue_status.sh'`

- Generate code:
  `ssh <your-worker-host-alias> 'DEFAULT_PROJECT=<project> OLLAMA_MODEL=<model> ~/worker/runner/run_task.sh codegen "<instruction>"'`

- Write code to file:
  `ssh <your-worker-host-alias> 'DEFAULT_PROJECT=<project> OLLAMA_MODEL=<model> ~/worker/runner/run_task.sh write <relative/path/file.ext> "<instruction>"'`

- Run tests:
  `ssh <your-worker-host-alias> 'DEFAULT_PROJECT=<project> ~/worker/runner/run_task.sh test'`

- Check runner is alive:
  `ssh <your-worker-host-alias> '~/worker/runner/run_task.sh ping'`
```

### Example usage in Claude Code

Once `CLAUDE.md` is in place, you can ask Claude Code naturally:

> "Use the Ollama runner to generate a Python function that validates email addresses"

Claude Code will SSH into the worker and run:
```bash
ssh worker-mac 'DEFAULT_PROJECT=my-project OLLAMA_MODEL=qwen2.5-coder:7b ~/worker/runner/run_task.sh codegen "write a Python function to validate email addresses"'
```

---

## Codex (OpenAI)

### Setup

Same SSH config as above. Codex (via the CLI or API with shell tool access) can run bash commands directly.

### Tell Codex about the runner

Add a system prompt or project instruction file:

```
You have access to a local Ollama instance on a worker machine via SSH.

To generate code using the local model:
  ssh <your-worker-host-alias> 'DEFAULT_PROJECT=<project> OLLAMA_MODEL=<model> ~/worker/runner/run_task.sh codegen "<instruction>"'

To check if Ollama is running and the queue is clear:
  ssh <your-worker-host-alias> '~/worker/runner/queue_status.sh'

Always check queue status before running a generation task. If Ollama is busy, wait or use 'queue_status.sh clean --force' to clear stale locks.
```

### Example usage in Codex

```
User: Generate a Go struct for a user profile with name, email, and created_at fields using the local Ollama runner.

Codex runs:
  ssh worker-mac 'DEFAULT_PROJECT=my-project OLLAMA_MODEL=qwen2.5-coder:7b ~/worker/runner/run_task.sh codegen "write a Go struct for a user profile with name, email, and created_at"'
```

---

## Environment variables reference

Set these on the worker machine before running the scripts:

| Variable | Default | Description |
|---|---|---|
| `DEFAULT_PROJECT` | _(none)_ | Project folder name for context |
| `OLLAMA_MODEL` | `qwen2.5-coder:32b` | Model to use — must be pulled with `ollama pull <model>` |
| `OLLAMA_URL` | `http://localhost:11434/api/generate` | Ollama API endpoint |
| `OLLAMA_TIMEOUT` | `300` | Max seconds to wait — increase for large models |
| `WORKER_ROOT` | `$HOME/worker` | Root working directory |
| `PROJECTS_DIR` | `$WORKER_ROOT/projects` | Directory containing projects |

---

## Notes

- The runner uses a **lock directory** (`$WORKER_ROOT/queue/ollama.lock.d`) to prevent concurrent Ollama calls. If a job crashes and leaves a stale lock, run `queue_status.sh clean` to clear it.
- Generation output is printed to stdout. For the `write` command, the output is written directly to the file and `WROTE: <path>` is printed instead.
- For OpenClaw users, see the main [README.md](README.md).


path: runner/queue_status.sh
content: #!/bin/bash
# queue_status.sh — Check and optionally clean the Ollama task queue on worker Mac
# Usage:
#   ./queue_status.sh                        # check status
#   ./queue_status.sh clean                  # clean stale locks (asks confirmation if Ollama busy)
#   ./queue_status.sh clean --force          # clean without prompting (safe for SSH/scripts)
#   ./queue_status.sh clean --kill-ollama    # stop Ollama server entirely (cancels active generation)

WORKER_ROOT="$HOME/worker"
QUEUE_DIR="$WORKER_ROOT/queue"
LOCK_DIR="$QUEUE_DIR/ollama.lock.d"
OLLAMA_API="http://localhost:11434"

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

echo ""
echo "=== Ollama Queue Status ==="
echo ""

# ── 1. Ollama server ──────────────────────────────────────────────────────────
echo -n "Ollama server:     "
if curl -s --max-time 3 "$OLLAMA_API/api/tags" > /dev/null 2>&1; then
  echo -e "${GREEN}running${NC}"
else
  echo -e "${RED}NOT RESPONDING${NC}"
  echo ""
  echo "Ollama is not running. Start it with: ollama serve"
  exit 1
fi

# ── 2. Active model ───────────────────────────────────────────────────────────
echo -n "Active model:      "
ACTIVE=$(curl -s --max-time 3 "$OLLAMA_API/api/ps" | \
  python3 -c "import sys,json; m=json.load(sys.stdin).get('models',[]); print(m[0]['name'] if m else '')" 2>/dev/null)
if [ -n "$ACTIVE" ]; then
  echo -e "${YELLOW}BUSY — $ACTIVE is generating${NC}"
  OLLAMA_BUSY=true
else
  echo -e "${GREEN}idle${NC}"
  OLLAMA_BUSY=false
fi

# ── 3. Lock directory ─────────────────────────────────────────────────────────
echo -n "Lock dir:          "
if [ -d "$LOCK_DIR" ]; then
  if [ -f "$LOCK_DIR/pid" ]; then
    LOCK_PID=$(cat "$LOCK_DIR/pid" 2>/dev/null)
    if kill -0 "$LOCK_PID" 2>/dev/null; then
      echo -e "${YELLOW}held by PID $LOCK_PID (alive)${NC}"
      LOCK_STATE="alive"
    else
      echo -e "${RED}STALE — PID $LOCK_PID is dead${NC}"
      LOCK_STATE="stale"
    fi
  else
    echo -e "${RED}STALE — no PID file (process killed before write)${NC}"
    LOCK_STATE="stale_nopid"
  fi
else
  echo -e "${GREEN}clear${NC}"
  LOCK_STATE="clear"
fi

# ── 4. Runner processes ───────────────────────────────────────────────────────
echo -n "Runner processes:  "
RUNNERS=$(pgrep -f "run_task.sh" 2>/dev/null | tr '\n' ' ')
CURLS=$(pgrep -f "curl.*11434" 2>/dev/null | tr '\n' ' ')
if [ -n "$RUNNERS" ] || [ -n "$CURLS" ]; then
  echo -e "${YELLOW}running${NC}"
  [ -n "$RUNNERS" ] && echo "  run_task.sh PIDs: $RUNNERS"
  [ -n "$CURLS"   ] && echo "  curl PIDs:        $CURLS"
else
  echo -e "${GREEN}none${NC}"
fi

# ── 5. Queue files ────────────────────────────────────────────────────────────
echo -n "Queue files:       "
PENDING=$(ls "$QUEUE_DIR/pending/" 2>/dev/null | wc -l | tr -d ' ')
DONE=$(ls "$QUEUE_DIR/done/" 2>/dev/null | wc -l | tr -d ' ')
FAILED=$(ls "$QUEUE_DIR/failed/" 2>/dev/null | wc -l | tr -d ' ')
echo "pending=$PENDING  done=$DONE  failed=$FAILED"

# ── 6. Summary ────────────────────────────────────────────────────────────────
echo ""
echo "=== Summary ==="

if [ "$OLLAMA_BUSY" = true ] && [ "$LOCK_STATE" = "alive" ]; then
  echo -e "${YELLOW}✓ Normal: Ollama is running a job with a valid lock${NC}"
  echo "  Wait for it to finish, or run with 'clean' to force-reset."

elif [ "$OLLAMA_BUSY" = true ] && [ "$LOCK_STATE" = "clear" ]; then
  echo -e "${RED}⚠ Orphan: Ollama is busy but no lock dir exists${NC}"
  echo "  A runner process started Ollama but was killed before acquiring/releasing lock."
  echo "  Ollama will finish its current job. Lock is clear so next job can start."

elif [ "$OLLAMA_BUSY" = false ] && [ "$LOCK_STATE" != "clear" ]; then
  echo -e "${RED}⚠ Stale lock: Ollama is idle but lock dir exists${NC}"
  echo "  Run with 'clean' to clear it."

elif [ "$OLLAMA_BUSY" = false ] && [ "$LOCK_STATE" = "clear" ]; then
  echo -e "${GREEN}✓ All clear: Ollama idle, no lock, ready for next job${NC}"
fi

echo ""

# ── 7. Clean (optional) ───────────────────────────────────────────────────────
if [ "${1:-}" = "clean" ]; then
  FORCE="${2:-}"
  echo "=== Cleaning ==="

  if [ "$OLLAMA_BUSY" = true ] && [ "$FORCE" = "--kill-ollama" ]; then
    echo -e "${RED}--kill-ollama: stopping Ollama server to cancel active generation...${NC}"
    pkill -9 -f "ollama" 2>/dev/null
    sleep 3
    echo "Restarting Ollama server..."
    nohup ollama serve > /tmp/ollama.log 2>&1 &
    sleep 5
    echo -e "${GREEN}Ollama restarted.${NC}"

  elif [ "$OLLAMA_BUSY" = true ]; then
    echo -e "${YELLOW}Warning: Ollama is actively generating internally.${NC}"
    echo "  Killing curl/runners will NOT stop it — Ollama processes requests internally."
    echo "  To fully stop: use 'clean --kill-ollama' to restart Ollama server."
    echo "  Or wait for it to finish naturally."
    if [ "$FORCE" != "--force" ]; then
      echo -n "  Still clean locks/runners? (y/N): "
      read -r confirm
      if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
        echo "Aborted."
        exit 0
      fi
    else
      echo "  --force: cleaning locks and runners only (Ollama will keep generating)."
    fi
  fi

  # Kill stuck runner bash processes (force kill)
  if [ -n "$RUNNERS" ]; then
    echo "Killing run_task.sh processes: $RUNNERS"
    kill -9 $RUNNERS 2>/dev/null
  fi

  # Kill curl processes
  if [ -n "$CURLS" ]; then
    echo "Killing curl processes: $CURLS"
    kill -9 $CURLS 2>/dev/null
  fi

  sleep 2

  # Remove stale lock
  if [ -d "$LOCK_DIR" ]; then
    echo "Removing lock dir: $LOCK_DIR"
    rm -rf "$LOCK_DIR"
  fi

  echo -e "${GREEN}Done. Processes killed.${NC}"
  echo ""

  # Wait for Ollama to settle after curl disconnect
  echo -n "Waiting for Ollama to settle..."
  for i in 1 2 3 4 5; do
    sleep 2
    ACTIVE2=$(curl -s --max-time 3 "$OLLAMA_API/api/ps" | \
      python3 -c "import sys,json; m=json.load(sys.stdin).get('models',[]); print(m[0]['name'] if m else '')" 2>/dev/null)
    if [ -z "$ACTIVE2" ]; then
      echo " idle"
      break
    fi
    echo -n "."
  done

  echo ""
  echo "=== Final Status ==="
  ACTIVE2=$(curl -s --max-time 3 "$OLLAMA_API/api/ps" | \
    python3 -c "import sys,json; m=json.load(sys.stdin).get('models',[]); print(m[0]['name'] if m else 'idle')" 2>/dev/null)
  RUNNERS2=$(pgrep -f "run_task.sh" 2>/dev/null | tr '\n' ' ')
  CURLS2=$(pgrep -f "curl.*11434" 2>/dev/null | tr '\n' ' ')
  echo "Ollama:  ${ACTIVE2:-idle}"
  echo "Lock:    $([ -d "$LOCK_DIR" ] && echo 'exists' || echo 'clear')"
  echo "Runners: ${RUNNERS2:-none}"
  echo "Curls:   ${CURLS2:-none}"
  echo ""
fi


path: runner/run_task.sh
content: #!/bin/bash
set -euo pipefail

# ─────────────────────────────────────────────────────────────────────────────
# run_task.sh — Ollama Task Runner
#
# Executes tasks via a local Ollama instance with exclusivity locking.
# Locking prevents concurrent Ollama calls that would degrade generation quality.
#
# Configuration (environment variables):
#   WORKER_ROOT     Root directory for the worker (default: $HOME/worker)
#   PROJECTS_DIR    Directory containing projects (default: $WORKER_ROOT/projects)
#   DEFAULT_PROJECT Default project name used for context (default: "")
#   OLLAMA_MODEL    Model to use for code generation (default: qwen2.5-coder:32b)
#   OLLAMA_URL      Ollama API endpoint (default: http://localhost:11434/api/generate)
#   OLLAMA_TIMEOUT  Max seconds to wait for Ollama response (default: 300)
# ─────────────────────────────────────────────────────────────────────────────

WORKER_ROOT="${WORKER_ROOT:-$HOME/worker}"
PROJECTS_DIR="${PROJECTS_DIR:-$WORKER_ROOT/projects}"
DEFAULT_PROJECT="${DEFAULT_PROJECT:-}"
OLLAMA_MODEL="${OLLAMA_MODEL:-qwen2.5-coder:32b}"
OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434/api/generate}"
OLLAMA_TIMEOUT="${OLLAMA_TIMEOUT:-300}"
ALLOW_NL_EXEC="${ALLOW_NL_EXEC:-false}"
DRY_RUN=false

QUEUE_DIR="$WORKER_ROOT/queue"
LOCK_DIR="$QUEUE_DIR/ollama.lock.d"

while [ "${1:-}" = "--dry-run" ]; do
  DRY_RUN=true
  shift
done

TASK="${1:-}"
shift || true

# ── Locking ───────────────────────────────────────────────────────────────────
# Only acquire lock for tasks that call Ollama
NEEDS_LOCK=false
if [ "$DRY_RUN" = false ]; then
  case "$TASK" in
    help|ping|list-projects|test|exec|"") NEEDS_LOCK=false ;;
    *) NEEDS_LOCK=true ;;
  esac
fi

acquire_lock() {
  mkdir "$LOCK_DIR" 2>/dev/null || {
    echo "ERROR: Ollama is locked by another task ($(cat "$LOCK_DIR/pid" 2>/dev/null || echo 'unknown PID')). Try again shortly or run queue_status.sh clean."
    exit 1
  }
  echo $$ > "$LOCK_DIR/pid"
}

release_lock() {
  rm -rf "$LOCK_DIR"
}

if [ "$NEEDS_LOCK" = true ]; then
  mkdir -p "$QUEUE_DIR"
  acquire_lock
  trap release_lock EXIT
fi

# ── Job status header ─────────────────────────────────────────────────────────
print_status() {
  local active
  local api_base="${OLLAMA_URL%/api/generate}"
  active=$(curl -s --max-time 3 "$api_base/api/ps" 2>/dev/null | python3 -c \
    "import sys,json; m=json.load(sys.stdin).get('models',[]); print(m[0]['name']+' (busy)' if m else 'idle')" 2>/dev/null || echo "unknown")
  local lock_state="clear"
  if [ -d "$LOCK_DIR" ]; then
    lock_state="locked (PID $(cat "$LOCK_DIR/pid" 2>/dev/null || echo '?'))"
  fi
  local mode="live"
  if [ "$DRY_RUN" = true ]; then
    mode="dry-run"
  fi
  echo "┌─ Job: $TASK | Mode: $mode | Model: ${OLLAMA_MODEL} | Ollama: $active | Lock: $lock_state"
  echo "└─ Started: $(date '+%H:%M:%S') | Timeout: ${OLLAMA_TIMEOUT}s"
}

echo "=== RUNNER START ==="
print_status

# ── Ollama generation ─────────────────────────────────────────────────────────
ollama_generate() {
  local instruction="$1"
  local code_only="${2:-false}"
  local prompt="$instruction"

  # Prepend project context if available
  if [ -n "$DEFAULT_PROJECT" ]; then
    local context_file="$PROJECTS_DIR/$DEFAULT_PROJECT/AGENT.md"
    if [ -f "$context_file" ]; then
      local context
      context=$(cat "$context_file")
      prompt="Project context:
$context

Task: $instruction"
    fi
  fi

  if [ "$code_only" = "true" ]; then
    prompt="$prompt

Return ONLY the complete source code. No explanation, no markdown fences, no prose. Start directly with the code."
  fi

  local json_prompt
  json_prompt=$(printf '%s' "$prompt" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')

  curl -s --max-time "$OLLAMA_TIMEOUT" "$OLLAMA_URL" -d "{
    \"model\": \"$OLLAMA_MODEL\",
    \"prompt\": $json_prompt,
    \"stream\": false
  }" | python3 -c 'import sys,json; print(json.load(sys.stdin)["response"])'
}

# ── Strip markdown fences — return pure code ──────────────────────────────────
strip_to_code() {
  python3 - "$1" << 'PYEOF'
import sys, re

text = open(sys.argv[1]).read()

blocks = re.findall(r'```(?:\w+)?\n(.*?)```', text, re.DOTALL)
if blocks:
    print(blocks[0].rstrip())
else:
    lines = text.splitlines()
    code_lines = [l for l in lines if not re.match(r'^(###|##|#|\*\*|--)', l)]
    print('\n'.join(code_lines).strip())
PYEOF
}

require_default_project() {
  if [ -z "$DEFAULT_PROJECT" ]; then
    echo "ERROR: DEFAULT_PROJECT is not set."
    exit 1
  fi
}

run_codegen() {
  local instruction="$1"
  if [ -z "$instruction" ]; then
    echo "ERROR: no instruction provided"
    echo "Usage: run_task.sh codegen \"write a function to...\""
    exit 1
  fi
  if [ "$DRY_RUN" = true ]; then
    echo "DRY RUN: would generate code"
    echo "Instruction: $instruction"
    return 0
  fi
  ollama_generate "$instruction" false
}

run_write() {
  local rel_file="$1"
  local instruction="$2"

  if [ -z "$rel_file" ] || [ -z "$instruction" ]; then
    echo "ERROR: missing file path or instruction"
    echo "Usage: run_task.sh write <relative/path/file.ext> <instruction>"
    exit 1
  fi

  require_default_project

  case "$rel_file" in
    /*|../*|*/../*|..)
      echo "ERROR: file path must stay inside the project directory."
      exit 1
      ;;
  esac

  local target_file="$PROJECTS_DIR/$DEFAULT_PROJECT/$rel_file"
  if [ "$DRY_RUN" = true ]; then
    echo "DRY RUN: would write generated code"
    echo "Target: $target_file"
    echo "Instruction: $instruction"
    return 0
  fi
  local tmp_file
  tmp_file=$(mktemp /tmp/ollama_output_XXXXXX)

  mkdir -p "$(dirname "$target_file")"
  ollama_generate "$instruction" true > "$tmp_file"
  strip_to_code "$tmp_file" > "$target_file"
  rm -f "$tmp_file"

  echo "WROTE: $target_file"
}

run_test() {
  local suite="${1:-all}"
  require_default_project

  local project_dir="$PROJECTS_DIR/$DEFAULT_PROJECT"
  if [ "$DRY_RUN" = true ]; then
    echo "DRY RUN: would run tests"
    echo "Project: $project_dir"
    echo "Suite: $suite"
    return 0
  fi
  cd "$project_dir"

  case "$suite" in
    all)
      if [ -f "backend/venv/bin/python" ]; then
        ./backend/venv/bin/python -m pytest backend/tests/ --verbose
      else
        echo "ERROR: Python venv not found at backend/venv"
        exit 1
      fi
      ;;
    *)
      if [ -f "backend/venv/bin/python" ]; then
        ./backend/venv/bin/python -m pytest "backend/tests/test_${suite}.py" --verbose
      else
        echo "ERROR: Python venv not found at backend/venv"
        exit 1
      fi
      ;;
  esac
}

run_exec() {
  local cmd="$1"
  if [ -z "$cmd" ]; then
    echo "ERROR: no command provided"
    exit 1
  fi
  if [ "$DRY_RUN" = true ]; then
    echo "DRY RUN: would run shell command"
    echo "Command: $cmd"
    return 0
  fi
  eval "$cmd"
}

run_list_projects() {
  if [ "$DRY_RUN" = true ]; then
    echo "DRY RUN: would list projects from $PROJECTS_DIR"
    return 0
  fi
  ls "$PROJECTS_DIR"
}

interpret_nl_request() {
  local request="$1"
  local project_context=""
  if [ -n "$DEFAULT_PROJECT" ]; then
    project_context="default project: $DEFAULT_PROJECT"
  else
    project_context="default project: unset"
  fi

  python3 - "$request" "$project_context" "$ALLOW_NL_EXEC" << 'PYEOF'
import json
import re
import sys

request = sys.argv[1].strip()
project_context = sys.argv[2]
allow_nl_exec = sys.argv[3].lower() == "true"
lower = request.lower()
path_pattern = r"[A-Za-z0-9_./-]+\.[A-Za-z0-9_+-]+"

def clean_text(value):
    return value.strip().strip("\"'`").strip(" .,:;!?")

def normalize_path(value):
    return clean_text(value)

def emit(action, instruction="", path="", suite="", command="", source="rule"):
    print(json.dumps({
        "action": action,
        "instruction": instruction,
        "path": path,
        "suite": suite,
        "command": command,
        "source": source,
    }))
    raise SystemExit

if not request:
    emit("error", instruction="empty request")

if re.search(r"\b(list|show)\b.*\bprojects\b", lower) or "what projects" in lower:
    emit("list-projects")

if re.search(r"\b(run|execute)?\s*tests?\b", lower) or lower.startswith("test "):
    suite = "all"
    suite_patterns = [
        r"\btests?\s+(?:for|on|in)\s+([a-zA-Z0-9_-]+)\b",
        r"\btest\s+([a-zA-Z0-9_-]+)\b",
        r"\b([a-zA-Z0-9_-]+)\s+tests?\b",
    ]
    for pattern in suite_patterns:
        match = re.search(pattern, lower)
        if match and match.group(1) not in {"all", "suite"}:
            suite = match.group(1)
            break
    emit("test", suite=suite)

write_patterns = [
    rf"\b(?:write|create|add|update|put|implement)\b(?P<instruction>.+?)\b(?:to|in|into)\s+(?P<path>{path_pattern})\b",
    rf"\b(?:write|create|add|update|put|implement)\b\s+(?P<path>{path_pattern})\s+(?:with|to|for)\s+(?P<instruction>.+)$",
    rf"\b(?:write|create|add|update|put|implement)\b\s+(?P<path>{path_pattern})\b(?P<instruction>.+)$",
    rf"\b(?:in|into)\s+(?P<path>{path_pattern})\b[,:\s]+(?P<instruction>.+)$",
]
for pattern in write_patterns:
    write_match = re.search(pattern, request, re.IGNORECASE)
    if write_match:
        path = normalize_path(write_match.group("path"))
        instruction = clean_text(write_match.group("instruction"))
        if instruction and path:
            emit("write", instruction=instruction, path=path)

quoted_path_match = re.search(
    rf"(?P<path>{path_pattern})",
    request,
    re.IGNORECASE,
)
if quoted_path_match and re.search(r"\b(?:file|path|module|script|component|class)\b", lower):
    path = normalize_path(quoted_path_match.group("path"))
    instruction = clean_text(re.sub(path_pattern, "", request, count=1, flags=re.IGNORECASE))
    if instruction and path:
        emit("write", instruction=instruction, path=path, source="path-hint")

codegen_match = re.search(
    r"\b(?:generate|show|draft|design|outline|write|create|make|build)\b\s+(?P<instruction>.+)$",
    request,
    re.IGNORECASE,
)
if codegen_match:
    emit("codegen", instruction=clean_text(codegen_match.group("instruction")))

if allow_nl_exec and re.search(r"\b(?:run|execute)\s+(?:command|shell)\b", lower):
    command = re.sub(r"^\s*(?:run|execute)\s+(?:command|shell)\s*", "", request, flags=re.IGNORECASE).strip()
    emit("exec", command=command)

print(json.dumps({
    "action": "route-with-llm",
    "instruction": request,
    "path": "",
    "suite": "",
    "command": "",
    "source": "fallback",
    "context": project_context,
}))
PYEOF
}

llm_route_request() {
  local request="$1"
  local router_prompt
  router_prompt=$(cat <<EOF
You are a strict task router for a local Ollama runner.

Allowed actions:
- codegen: generate code or a technical answer
- write: generate code and write it to a relative project file
- test: run the test suite, optionally with a suite name
- list-projects: list available projects
$( [ "$ALLOW_NL_EXEC" = "true" ] && echo "- exec: run a shell command" )

Rules:
- Return exactly one JSON object.
- Do not wrap JSON in markdown fences.
- If the user asks to modify a file and names a file path, use action "write".
- For "write", include "path" and "instruction".
- For "codegen", include "instruction".
- For "test", include "suite" and use "all" if unspecified.
- For "list-projects", no extra fields are needed.
- Use "exec" only if the request clearly asks to run a shell command and exec is allowed.
- If the request is ambiguous, prefer "codegen" over "exec".
- Relative file paths only. Never use absolute paths or "..".

Return JSON with these keys:
{"action":"","instruction":"","path":"","suite":"","command":""}

User request:
$request
EOF
)

  ollama_generate "$router_prompt" false
}

validate_route_json() {
  local raw_json="$1"
  python3 - "$raw_json" "$ALLOW_NL_EXEC" << 'PYEOF'
import json
import re
import sys

raw = sys.argv[1]
allow_nl_exec = sys.argv[2].lower() == "true"

try:
    data = json.loads(raw)
except json.JSONDecodeError as exc:
    print(f"ERROR: could not parse route JSON: {exc}")
    raise SystemExit(1)

action = data.get("action", "")
if action not in {"codegen", "write", "test", "list-projects", "exec"}:
    print(f"ERROR: unsupported routed action: {action}")
    raise SystemExit(1)

if action == "exec" and not allow_nl_exec:
    print("ERROR: NL exec routing is disabled. Set ALLOW_NL_EXEC=true to enable it.")
    raise SystemExit(1)

path = data.get("path", "")
if path:
    if path.startswith("/") or ".." in path.split("/"):
        print("ERROR: routed file path must stay inside the project directory.")
        raise SystemExit(1)
    if not re.match(r"^[A-Za-z0-9_./+-]+$", path):
        print("ERROR: routed file path contains unsupported characters.")
        raise SystemExit(1)

suite = data.get("suite", "") or "all"
instruction = data.get("instruction", "")
command = data.get("command", "")

print(json.dumps({
    "action": action,
    "instruction": instruction,
    "path": path,
    "suite": suite,
    "command": command,
}))
PYEOF
}

run_nl() {
  local request="$1"
  if [ -z "$request" ]; then
    echo "ERROR: no natural-language request provided"
    echo "Usage: run_task.sh [--dry-run] nl \"write a retry decorator in src/utils.py\""
    exit 1
  fi

  local route_json
  route_json=$(interpret_nl_request "$request")
  local action
  action=$(printf '%s' "$route_json" | python3 -c 'import sys,json; print(json.load(sys.stdin)["action"])')

  if [ "$action" = "route-with-llm" ]; then
    route_json=$(llm_route_request "$request")
  fi

  route_json=$(validate_route_json "$route_json")

  local routed_action instruction path suite command
  routed_action=$(printf '%s' "$route_json" | python3 -c 'import sys,json; print(json.load(sys.stdin)["action"])')
  instruction=$(printf '%s' "$route_json" | python3 -c 'import sys,json; print(json.load(sys.stdin)["instruction"])')
  path=$(printf '%s' "$route_json" | python3 -c 'import sys,json; print(json.load(sys.stdin)["path"])')
  suite=$(printf '%s' "$route_json" | python3 -c 'import sys,json; print(json.load(sys.stdin)["suite"])')
  command=$(printf '%s' "$route_json" | python3 -c 'import sys,json; print(json.load(sys.stdin)["command"])')

  echo "Interpreted request as: $routed_action"
  case "$routed_action" in
    codegen)
      [ -n "$instruction" ] && echo "Instruction: $instruction"
      run_codegen "$instruction"
      ;;
    write)
      echo "Path: $path"
      [ -n "$instruction" ] && echo "Instruction: $instruction"
      run_write "$path" "$instruction"
      ;;
    test)
      echo "Suite: ${suite:-all}"
      run_test "${suite:-all}"
      ;;
    list-projects)
      run_list_projects
      ;;
    exec)
      echo "Command: $command"
      run_exec "$command"
      ;;
  esac
}

# ── Commands ──────────────────────────────────────────────────────────────────
case "$TASK" in
  help)
    cat << 'HELP'
Ollama Task Runner — powered by a local Ollama model.

Commands:

  ping
    Check runner is alive.

  codegen <instruction>
    Generate code from an instruction using the configured Ollama model.
    Usage: run_task.sh [--dry-run] codegen "write a Python function to parse JSON"

  nl <request>
    Interpret a natural-language request, route it safely, then execute it.
    Usage: run_task.sh [--dry-run] nl "write a retry decorator in src/utils.py"

  write <relative/path/file.ext> <instruction>
    Generate code and write it to a file inside the project directory.
    Prose and markdown fences are stripped — file gets pure code.
    Usage: run_task.sh [--dry-run] write src/utils.py "write a retry decorator"

  test [suite]
    Run tests for the configured project.
    Usage: run_task.sh [--dry-run] test
    Usage: run_task.sh [--dry-run] test <suite_name>

  exec <shell command>
    Run any shell command on this machine.
    Usage: run_task.sh [--dry-run] exec "ls ~/worker/projects"

  list-projects
    List available projects in PROJECTS_DIR.

Environment variables:
  WORKER_ROOT, PROJECTS_DIR, DEFAULT_PROJECT, OLLAMA_MODEL, OLLAMA_URL, ALLOW_NL_EXEC

HELP
    ;;

  ping)
    echo "RUNNER_OK"
    ;;

  list-projects)
    run_list_projects
    ;;

  test)
    SUITE="${1:-all}"
    run_test "$SUITE"
    ;;

  codegen)
    INSTRUCTION="${*}"
    run_codegen "$INSTRUCTION"
    ;;

  write)
    REL_FILE="${1:-}"
    shift || true
    INSTRUCTION="${*}"
    run_write "$REL_FILE" "$INSTRUCTION"
    ;;

  exec)
    CMD="${*}"
    run_exec "$CMD"
    ;;

  nl)
    REQUEST="${*}"
    run_nl "$REQUEST"
    ;;

  "")
    echo "ERROR: no task specified. Run: run_task.sh help"
    exit 1
    ;;

  *)
    run_nl "$TASK${*:+ $*}"
    ;;
esac

echo "=== RUNNER END ==="


path: install.sh
content: #!/bin/bash
# install.sh — Set up or update the Ollama Task Orchestrator runner
# Run this after cloning or pulling the repo.
#
# Usage:
#   ./install.sh                        # installs to ~/worker/runner/
#   RUNNER_DIR=/custom/path ./install.sh  # installs to a custom path

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RUNNER_DIR="${RUNNER_DIR:-$HOME/worker/runner}"

echo "Ollama Task Orchestrator — installer"
echo "Runner target: $RUNNER_DIR"
echo ""

mkdir -p "$RUNNER_DIR"

cp "$SCRIPT_DIR/runner/run_task.sh" "$RUNNER_DIR/run_task.sh"
cp "$SCRIPT_DIR/runner/queue_status.sh" "$RUNNER_DIR/queue_status.sh"
chmod +x "$RUNNER_DIR/run_task.sh" "$RUNNER_DIR/queue_status.sh"

echo "✓ Installed runner scripts to $RUNNER_DIR"
echo ""
echo "Next steps:"
echo "  1. Set DEFAULT_PROJECT and OLLAMA_MODEL in your shell profile (~/.zshrc or ~/.bashrc)"
echo "  2. Test with: $RUNNER_DIR/run_task.sh ping"
echo "  3. Check Ollama: $RUNNER_DIR/queue_status.sh"
echo ""
echo "To update in future: git pull && ./install.sh"


path: README.md
content: # ollama-task-orchestrator

An [OpenClaw](https://openclaw.ai) skill that lets your AI agents queue and execute tasks on a local Ollama instance running on a worker Mac (or any machine accessible via SSH).

Agents can check Ollama's queue health, run code generation tasks, write files, run tests, or execute arbitrary shell commands — all through a simple `ollama status` / `ollama run` interface.

---

## Features

- **Queue status** — check if Ollama is idle, busy, or has stale locks
- **Code generation** — generate code from natural language instructions via Ollama
- **File writing** — generate and write code directly to project files
- **Exclusivity locking** — prevents concurrent Ollama calls that degrade output quality
- **SSH-based** — works from any OpenClaw agent on a VPS or remote machine
- **Fully configurable** — no hardcoded paths or hostnames

---

## Requirements

- A Mac (or Linux machine) with [Ollama](https://ollama.ai) installed and running
- SSH access from your OpenClaw host to the worker machine
- Python 3 on the worker machine
- `curl` on the worker machine

---

## Installation

### 1. Set up the runner on your worker machine

Clone the repo and run the installer on each machine you want to use as a worker:

```bash
git clone https://github.com/RajeshHuria/ollama-task-orchestrator.git
cd ollama-task-orchestrator
./install.sh
```

This installs the runner scripts to `~/worker/runner/` by default. To use a custom path:

```bash
RUNNER_DIR=/custom/path ./install.sh
```

To update the runner scripts later:

```bash
git pull && ./install.sh
```

### 2. Configure SSH

Make sure your OpenClaw host can SSH into the worker without a password prompt. Add an entry to `~/.ssh/config` on your OpenClaw host:

```
Host <your-worker-host-alias>
  HostName <your-worker-ip-or-hostname>
  User <your-username>
  IdentityFile ~/.ssh/id_ed25519
```

### 3. Add the skill to OpenClaw

Copy the skill directory into your OpenClaw skills directory:

```bash
cp -r ollama-task-orchestrator/ ~/.openclaw/skills/
```

Then add it to your agent(s) in `openclaw.json`:

```json
{
  "id": "your-agent",
  "skills": ["ollama-task-orchestrator"]
}
```

### 4. Configure

Set these environment variables on your **OpenClaw host** to match your setup:

| Variable | Default | Description |
|---|---|---|
| `OLLAMA_WORKER_HOST` | `worker-mac` | SSH host alias defined in `~/.ssh/config` |
| `OLLAMA_RUNNER_PATH` | `~/worker/runner` | Path to the runner scripts on the worker machine |

Set these on your **worker machine** (e.g. in `~/.zshrc` or `~/.bashrc`):

| Variable | Default | Description |
|---|---|---|
| `WORKER_ROOT` | `$HOME/worker` | Root working directory |
| `PROJECTS_DIR` | `$WORKER_ROOT/projects` | Directory containing your projects |
| `DEFAULT_PROJECT` | _(none — **must set** for codegen/write/test)_ | Project folder name used for context and file writes |
| `OLLAMA_MODEL` | `qwen2.5-coder:32b` | Ollama model to use — change to any model you have pulled |
| `OLLAMA_URL` | `http://localhost:11434/api/generate` | Ollama API endpoint |
| `OLLAMA_TIMEOUT` | `300` | Max seconds to wait for Ollama response before giving up. Increase for large models or slow hardware — local models can take a while to load on first run. |

> **Important:** `DEFAULT_PROJECT` and `OLLAMA_MODEL` have no automatic defaults tied to any specific project or model. Set `DEFAULT_PROJECT` to your project folder name and `OLLAMA_MODEL` to a model you have pulled with `ollama pull <model>`.

---

## Usage

### Check queue status

```
ollama status
```

Returns Ollama server health, active model, lock state, and queue file counts.

```
ollama status clean           # clear stale locks (prompts if Ollama is busy)
ollama status clean --force   # clear without prompting
```

### Run a task

```
ollama run ping
ollama run codegen "write a Python function to validate email addresses"
ollama run write src/utils.py "write a retry decorator with exponential backoff"
ollama run nl "write a retry decorator in src/utils.py"
ollama run "run tests for auth"
ollama run "add a retry decorator to src/utils.py"
ollama run "update src/api.py with a FastAPI endpoint for user login"
ollama run "generate a Go struct for a user profile"
ollama run --dry-run "add a retry decorator to src/utils.py"
ollama run --dry-run test auth
ollama run test
ollama run exec "ls $PROJECTS_DIR"
ollama run list-projects
```

---

## How it works

```
OpenClaw Agent
     │
     │  skill.py (SSH)
     ▼
Worker Mac
     ├── runner/queue_status.sh   — checks Ollama API + lock directory
     └── runner/run_task.sh       — sends prompts to Ollama, writes output
```

The skill uses a **lock directory** (`$WORKER_ROOT/queue/ollama.lock.d`) to enforce that only one task runs at a time. If a task crashes without releasing the lock, `ollama status clean` removes it safely.

---

## Using with Claude Code or Codex

The `runner/` scripts are plain bash with no OpenClaw dependency. They can be called over SSH from any tool that can run shell commands — including Claude Code and Codex.

See **[README-claude-code.md](README-claude-code.md)** for setup instructions and example prompts for both tools.

---

## License

MIT — see [LICENSE](LICENSE)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)