Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 35 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ prompt_template = "templates/my-prompt.md" # required for claude/default phases
model = "claude-sonnet-4-6" # default: claude-sonnet-4-6
effort = "medium" # low | medium | high
timeout = 300 # seconds; must be > 0
runtime = "claude" # "claude" (default) | "deterministic"
runtime = "claude" # "claude" (default) | "openrouter" | "deterministic"
api_key_env = "OPENROUTER_API_KEY" # openrouter only — env var holding the API key (default: OPENROUTER_API_KEY)
bare = false # true → append --bare (skips session/MCP/skill loading; ~96% cold-start reduction)

# Completion routing
[completion]
Expand Down Expand Up @@ -315,18 +317,20 @@ Exit 0 = passed. Any non-zero exit = failed. Stdout/stderr are captured as the f

## Runtime Configuration

BOI is runtime-agnostic. The default runtime is `claude` (Claude Code CLI). `codex` (Codex CLI) is also supported.
BOI is runtime-agnostic. The default runtime is `claude` (Claude Code CLI). `codex` (Codex CLI) and `openrouter` (direct HTTP to OpenRouter API) are also supported.

### Global Default

Set in `~/.boi/config.json`:
Set in `~/.boi/config.yaml`:

```json
{
"runtime": { "default": "claude" }
}
```yaml
runtime:
default: claude
brain: ~/mrap-hex # optional — path to brain dir; must contain CLAUDE.md
```

`brain` sets the default brain directory for all specs. Workers read `{brain}/CLAUDE.md` as system context before each task. BOI errors early if `brain` is set but the path or `CLAUDE.md` is missing.

### Per-Spec Override

Add a `runtime:` field to any spec:
Expand All @@ -339,13 +343,18 @@ Spec-level override takes precedence over the global default.

### Model Mappings

Phase config accepts either full model IDs or aliases (`opus`, `sonnet`, `haiku`). The runtime resolves them:
Phase config accepts either full model IDs or aliases. The runtime resolves them:

| Alias | Claude | Codex |
|-------|--------|-------|
| `opus` | claude-opus-4-6 | o3 |
| `sonnet` | claude-sonnet-4-6 | o4-mini |
| `haiku` | claude-haiku-4-5-20251001 | o4-mini |
| Alias | Claude | Codex | OpenRouter |
|-------|--------|-------|------------|
| `opus` | claude-opus-4-6 | o3 | — |
| `sonnet` | claude-sonnet-4-6 | o4-mini | — |
| `haiku` | claude-haiku-4-5-20251001 | o4-mini | anthropic/claude-haiku-4-5 |
| `gemini-flash` | — | — | google/gemini-2.0-flash-001 |
| `grok` | — | — | x-ai/grok-beta |
| `qwen-coder` | — | — | qwen/qwen-2.5-coder-32b-instruct |

OpenRouter phases require `OPENROUTER_API_KEY` in the environment and a `model` field in `[worker]`. Use `openrouter` runtime for text-only judgment phases (critic, plan-critique, spec-critique) to skip Claude cold-start and reduce cost.

### CLI Check

Expand All @@ -358,6 +367,7 @@ boi dispatch <file.yaml> [options] Submit a spec to the queue
boi status [--watch] [--json] Show queue and worker status
boi log <queue-id> [--full] [-f|--follow] Tail worker output for a spec
boi cancel <queue-id> Cancel a running or queued spec
boi daemon reload Send SIGHUP to reload max_workers/spawns_per_tick/claude_bin
boi stop Stop daemon and all workers
boi install [--workers N] One-time setup (run outside Claude Code)
boi resume <queue-id> | --all Resume failed or canceled specs
Expand All @@ -372,6 +382,8 @@ boi dep add|remove|set|clear|show|viz|check
boi project create|list|status|context|delete
boi bench --pipeline name:path [--pipeline ...] --spec FILE | --battery DIR [--runs N] Benchmark N pipelines
boi bench --phase <name> --spec FILE [--runs N] Benchmark a single phase in isolation
boi plan [spec.yaml ...] [--force-refresh] Build DAG + LLM critique for in-flight and new specs
boi dispatch-many <spec1.yaml> [spec2.yaml ...] DAG-ordered multi-spec dispatch with LLM gate
```

**`dispatch` options:**
Expand All @@ -385,6 +397,16 @@ boi bench --phase <name> --spec FILE [--runs N] Benchmark a single phase in iso
| `--after SA7F3,TB2E1` | Wait for listed specs to complete before starting |
| `--project NAME` | Associate with a project (injects project context) |

**`dispatch-many` options:**

| Flag | Description |
|------|-------------|
| `--yes` | Auto-approve dispatch without interactive prompt |
| `--force` | Override warn-level concerns (cannot override blocks) |
| `--priority N` | Priority applied to all dispatched specs (default: 100) |
| `--mode MODE` | Mode applied to all specs |
| `--after SA7F3` | Additional upstream dep for all dispatched specs |

## Output Preservation

BOI automatically preserves the work product of every completed spec so outputs are never lost when the worktree is cleaned up.
Expand Down
8 changes: 6 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ boi critic status | run | enable | disable | checks
boi spec <queue-id> [add|skip|next|block|edit|deps]
boi dep add|remove|set|clear|show|viz|check
boi project create|list|status|context|delete
boi plan [spec.yaml ...] [--force-refresh] Build DAG + LLM critique for in-flight and new specs
boi dispatch-many <spec1.yaml> [spec2.yaml ...] DAG-ordered multi-spec dispatch with LLM gate
```

## Spec Format
Expand Down Expand Up @@ -141,7 +143,9 @@ prompt_template = "path/to/prompt.md" # required for claude phases
model = "claude-sonnet-4-6"
effort = "medium" # low | medium | high
timeout = 300 # seconds
runtime = "claude" # "claude" (default) | "deterministic"
runtime = "claude" # "claude" (default) | "openrouter" | "deterministic"
api_key_env = "OPENROUTER_API_KEY" # openrouter only — env var holding the API key (default: OPENROUTER_API_KEY)
bare = false # true → --bare flag (skips session/MCP/skill loading; ~96% cold-start reduction)

[completion]
approve_signal = "## Approved"
Expand Down Expand Up @@ -293,7 +297,7 @@ Exit 0 = passed. Any non-zero = failed.
## Constraints

- `boi install` runs **outside Claude Code** in a terminal.
- Workers are headless, non-interactive CLI agent sessions. Default runtime: `claude -p`. Codex runtime: `codex exec`. Configured globally in `~/.boi/config.json` or per-spec via `**Runtime:** codex` header.
- Workers are headless, non-interactive CLI agent sessions. Default runtime: `claude -p`. Codex runtime: `codex exec`. OpenRouter runtime: direct HTTP to `openrouter.ai/api/v1/chat/completions` (requires `OPENROUTER_API_KEY`; used for text-only judgment phases). Configured globally in `~/.boi/config.yaml` or per-spec via `**Runtime:** codex` header.
- Daemon polls every 5 seconds. Status may lag slightly.
- Default 3 workers, max 5. Set during install.
- Workers get fresh context each iteration. No memory of previous iterations.
Expand Down
78 changes: 78 additions & 0 deletions docs/daemon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# BOI Daemon

## Overview

The BOI daemon is a long-running process that monitors the queue and dispatches workers for pending specs. It is started with `boi daemon start` (background) or `boi daemon foreground` (attached to the terminal).

## Tick Cadence

The daemon polls every ~5 seconds (10 × 500 ms sleep increments). Each tick:

1. Writes a heartbeat timestamp to `~/.boi/daemon.heartbeat`.
2. Checks the SIGHUP reload flag and applies config changes if set.
3. Reaps finished worker threads.
4. Computes how many new workers to spawn this tick and drains the queue up to that cap.
5. Sleeps 500 ms × 10 before the next tick (interruptible by SIGTERM).

## Batched Dequeue (`spawns_per_tick`)

Rather than spawning one worker per tick, the daemon drains up to `spawns_per_tick` eligible specs per tick (default 4). The actual number spawned is:

```
to_spawn = min(max_workers - current_workers, spawns_per_tick)
```

A 50–150 ms randomized jitter is inserted between successive spawns within a single tick to smooth cold-start bursts on the Anthropic API. Configure `spawns_per_tick` in `~/.boi/config.yaml`:

```yaml
spawns_per_tick: 4 # default; raise once cold-start behavior is validated
```

## SIGHUP Config Hot-Reload

Sending SIGHUP to the daemon triggers a live config reload **without restarting** or interrupting in-flight workers.

### What reloads

| Setting | Reloaded? |
|---------|-----------|
| `max_workers` | Yes |
| `spawns_per_tick` | Yes |
| `claude_bin` | Yes |
| `task_timeout_minutes` | No — startup snapshot |
| `retry_count` | No — startup snapshot |
| `cleanup_on_failure` | No — startup snapshot |
| `paths.*` | No — startup snapshot |

### Reload semantics

- **Parse failure is a no-op.** If the config file is syntactically invalid, the daemon logs `[boi daemon] reload FAILED: ...; keeping current config` and retains the current values.
- **In-flight workers are unaffected.** Workers receive a snapshot of `WorkerConfig` at spawn time; live config mutation never reaches them.
- **No restart required.** The daemon process continues running; only the three live fields are updated.

### Triggering a reload

```bash
# Recommended: set a value then reload in one step
boi config set max_workers 10
boi daemon reload

# Or send SIGHUP directly
kill -HUP $(cat ~/.boi/daemon.lock)
```

`boi daemon reload` reads the PID from `~/.boi/daemon.lock`, verifies the process is alive, and sends SIGHUP. The reload takes effect within the next tick (≤ 5 seconds).

## Daemon Commands

| Command | Description |
|---------|-------------|
| `boi daemon start` | Start daemon in the background |
| `boi daemon stop` | Send SIGTERM; waits up to 10s, then SIGKILL |
| `boi daemon restart` | Stop + start |
| `boi daemon foreground` | Run attached to the terminal |
| `boi daemon reload` | Send SIGHUP to reload `max_workers`, `spawns_per_tick`, `claude_bin` |

## PID and Lock File

The daemon uses an exclusive `flock` on `~/.boi/daemon.lock` (which also stores the PID) as its singleton guard. This is crash-safe: the lock auto-releases when the process exits, so stale PID files can never block a restart.
6 changes: 4 additions & 2 deletions src/cli/config_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub fn cmd_config(key: Option<&str>, value: Option<&str>, cfg: &config::Config)
match (key, value) {
(None, _) => {
println!("max_workers: {}", cfg.max_workers());
println!("spawns_per_tick: {}", cfg.spawns_per_tick());
println!("task_timeout_minutes: {}", cfg.task_timeout_secs() / 60);
println!("retry_count: {}", cfg.retry_count());
println!("db_path: {}", cfg.db_path().display());
Expand All @@ -21,6 +22,7 @@ pub fn cmd_config(key: Option<&str>, value: Option<&str>, cfg: &config::Config)
(Some(k), None) => {
let val = match k {
"max_workers" => cfg.max_workers().to_string(),
"spawns_per_tick" => cfg.spawns_per_tick().to_string(),
"task_timeout_minutes" => (cfg.task_timeout_secs() / 60).to_string(),
"retry_count" => cfg.retry_count().to_string(),
"db_path" => cfg.db_path().display().to_string(),
Expand All @@ -36,9 +38,9 @@ pub fn cmd_config(key: Option<&str>, value: Option<&str>, cfg: &config::Config)
(Some(k), Some(v)) => {
// Validate key
match k {
"max_workers" | "task_timeout_minutes" | "retry_count" => {}
"max_workers" | "spawns_per_tick" | "task_timeout_minutes" | "retry_count" => {}
_ => {
eprintln!("unknown config key: {} (supported: max_workers, task_timeout_minutes, retry_count)", k);
eprintln!("unknown config key: {} (supported: max_workers, spawns_per_tick, task_timeout_minutes, retry_count)", k);
std::process::exit(1);
}
}
Expand Down
Loading