diff --git a/CHANGELOG.md b/CHANGELOG.md
index a088252..caec4aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [4.0.0] Β· 2026-04-30
+
+### Added
+- **Cross runtime adapter layer**: OpenClaw remains the primary stack for Fleet, and v4 extends orchestration, monitoring, reliability judging, routing, and delta tracking across Claude Code, Codex, Cursor, Windsurf, Gemini CLI, and custom agent stacks. Four built in adapters ship with the release.
+- `lib/core/adapters.sh`: registry, dispatcher, validator, and parallel probe helper. Each adapter implements a six function contract (`describe`, `verified`, `required`, `health`, `info`, `version`) and must complete within `FLEET_ADAPTER_TIMEOUT` seconds (default 6).
+- `lib/adapters/openclaw.sh`: verified probe of OpenClaw gateways via `/health` with authenticated `/v1/models` fallback when a token is configured. Preserves v3 OpenClaw behavior exactly.
+- `lib/adapters/http.sh`: generic HTTP probe with `expectedStatus`, custom `method`, optional bearer token, repeatable `headers`, and an optional `versionUrl`.
+- `lib/adapters/docker.sh`: queries the local docker daemon for container `State.Status` and `State.Health.Status`. Degrades gracefully when the docker CLI is missing.
+- `lib/adapters/process.sh`: detects the presence of an OS process via `pgrep`. Marked inferred because there is no protocol level handshake.
+- `fleet adapters`: lists registered adapters, marks each as verified or inferred, shows which adapter applies to every configured agent and runtime entry.
+- `fleet runtime add [options]`: registers a new entry under the `runtimes` key in config without manual JSON editing. Validates required fields per adapter, refuses names already used by an agent, writes the config atomically with `chmod 600`.
+- `fleet runtime test `: one off probe of any runtime or agent. Animated spinner runs while health, info, and version probes execute in parallel, then renders all three sections.
+- `fleet runtime list`: shows live status of every runtime, probed in parallel with an animated progress indicator on TTY output.
+- `fleet runtime rm `: removes a runtime from the config, with explicit not found feedback.
+- `fleet policy`: shows, toggles, scopes, requires, relaxes, edits, clears, and previews the optional operator constitution.
+- Optional `constitution` config block: prepends customizable operator rules to `fleet task`, `fleet parallel`, and `fleet steer` dispatches, with optional required mode that blocks scoped dispatches when rules are disabled or empty.
+- `runtimes` key in config: optional array of entries with at least `name` and `adapter`. Backward compatible: configs without the key behave exactly like v3.
+- User adapter directory: drop a custom `.sh` file into `~/.fleet/adapters/` (or override via `FLEET_ADAPTERS_DIR`) and it is auto loaded with the same six function contract as the built ins.
+- `FLEET_ADAPTER_TIMEOUT` and `FLEET_ADAPTERS_DIR` environment variables documented in README and `_meta.json`.
+- `examples/cross-runtime/config.json`: full mixed runtime example covering openclaw, http, docker, and process adapters.
+
+### Changed
+- `bin/fleet`: routes `adapters` and `runtime|runtimes` subcommands, sources `lib/core/adapters.sh` alongside the other core libraries, exports `FLEET_ROOT`. Help text adds a CROSS RUNTIME section.
+- `fleet agents`: a new Runtimes section renders all v4 entries with adapter, endpoint, status, verified or inferred tag, and elapsed milliseconds. Probes run in parallel with an animated progress line on TTY output.
+- `fleet health`: a new Runtimes section probes every runtime entry through the adapter layer, in parallel.
+- `fleet sitrep`: tracks runtime state alongside agents and CI. Runtime status changes appear in the CHANGED section with `runtime : β ` lines. Probing runs in a thread pool so a single slow runtime does not stall the sitrep.
+- `templates/configs/full.json`: includes a runtimes block with one example per adapter type. `templates/configs/minimal.json` reserves the `runtimes` key.
+- `_meta.json`: bumped version to 4.0.0, added `FLEET_ADAPTER_TIMEOUT` and `FLEET_ADAPTERS_DIR` to optional env vars, added the user adapter directory and runtimes config to permissions reads.
+
+### Backward Compatibility
+- Configs from v1 through v3 work without changes. Entries under `agents` continue to use the openclaw adapter by default. The runtime layer is purely additive.
+
+---
+
## [3.0.5] Β· 2026-04-30
### Fixed
diff --git a/README.md b/README.md
index dfa3ee9..354c895 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-Multi-agent fleet management for OpenClaw
+Multi-agent fleet management for OpenClaw, Claude Code, Codex, Cursor, Windsurf, Gemini CLI, and custom agent stacks
One CLI to monitor, manage, and orchestrate your entire AI agent fleet.
@@ -29,9 +29,9 @@
---
-You're running multiple [OpenClaw](https://openclaw.ai) gateways: a coordinator that thinks, employees that code, review, deploy, and research. Fleet is the operational layer that was missing. One CLI that gives your coordinator full visibility, control, and judgment over the entire fleet.
+Fleet brings OpenClaw, Claude Code, Codex, Cursor, Windsurf, Gemini CLI, and custom agent stacks into one multi-agent fleet management CLI to orchestrate, monitor, judge reliability, and route intelligently across any runtime with delta tracking.
-See what changed. Dispatch tasks. Learn which agents actually deliver, and route work to whoever you can trust. Works with any runtime.
+Dispatch work, steer live sessions, track outcomes, and keep OpenClaw gateways, HTTP workers, Docker workers, and local processes accountable from one CLI. Built for every operator running more than one agent or runtime.
Built for AI agents to manage AI agents. Works on any system π¦
@@ -80,7 +80,7 @@ See what changed. Dispatch tasks. Learn which agents actually deliver, and route
# Install via ClawHub
clawhub install fleet
-# Or via skills.sh (works with Claude Code, Codex, Cursor, Windsurf, and more)
+# Or via skills.sh for OpenClaw, Claude Code, Codex, Cursor, Windsurf, Gemini CLI, and shell capable agents
npx skills add oguzhnatly/fleet
# Or clone directly
@@ -100,21 +100,47 @@ fleet sitrep
| Command | Description |
|---------|-------------|
| `fleet task ""` | Dispatch a task to an agent, stream response live |
-| `fleet steer ""` | Send a mid-session correction to a running agent |
+| `fleet steer ""` | Send a mid-session correction to a running agent, with policy injection when enabled |
| `fleet watch ` | Live session tail: polls every 3s, shows new messages as they arrive |
| `fleet parallel ""` | Decompose into subtasks, assign by type, dispatch all concurrently |
| `fleet kill ` | Send a graceful stop signal to an agent session |
| `fleet log` | Append-only structured log of all dispatches and outcomes |
+| `fleet policy` | Show optional operator constitution rules applied to dispatched tasks |
### Monitoring
| Command | Description |
|---------|-------------|
-| `fleet health` | Health check all gateways and endpoints |
-| `fleet agents` | Show agent fleet with live status and latency |
-| `fleet sitrep [hours]` | Full SITREP with delta tracking |
+| `fleet health` | Health check all gateways, endpoints, and configured runtimes |
+| `fleet agents` | Show agent fleet with live status and latency, plus configured runtimes |
+| `fleet sitrep [hours]` | Full SITREP with delta tracking across agents, CI, runtimes |
| `fleet audit` | Check for misconfigurations and risks |
+### Operator Constitution
+
+| Command | Description |
+|---------|-------------|
+| `fleet policy` | Show configured constitution status, scope, and rules |
+| `fleet policy enable` / `fleet policy disable` | Toggle constitution injection |
+| `fleet policy require` / `fleet policy optional` | Require valid constitution coverage before dispatch, or relax back to prompt only mode |
+| `fleet policy add ""` | Add a rule and enable the constitution |
+| `fleet policy rm ` / `fleet policy clear` | Remove one rule or clear all rules |
+| `fleet policy title ""` | Customize the rule block title |
+| `fleet policy scope task,parallel,steer` | Choose which commands receive the rule block |
+| `fleet policy preview ""` | Preview the exact task prompt after constitution injection |
+
+Set `constitution.enabled` in config or use `fleet policy add` to prepend custom rules to `fleet task`, `fleet parallel`, and `fleet steer` dispatches. Use `fleet policy require` to block scoped dispatches when the constitution is disabled or empty. This is useful for team rules such as required tests, repository instructions, or no history rewrite unless the operator declares an emergency.
+
+### Cross-Runtime (v4)
+
+| Command | Description |
+|---------|-------------|
+| `fleet adapters` | List registered adapters and their bindings to agents and runtimes |
+| `fleet runtime add ` | Register a new runtime (openclaw, http, docker, process) |
+| `fleet runtime test ` | One-off probe of a runtime or agent (health + info + version) |
+| `fleet runtime list` | Live status of every runtime, probed in parallel |
+| `fleet runtime rm ` | Remove a runtime from the config |
+
### Development
| Command | Description |
@@ -181,13 +207,13 @@ Watching coordinator
Connecting to coordinator session...
Last 3 message(s):
- coordinator (claude-sonnet-4-6) 15:10 UTC
+ coordinator (strategic-default) 15:10 UTC
Running fleet sitrep...
you 15:23 UTC
build the pricing page
- coordinator (claude-sonnet-4-6) 15:24 UTC
+ coordinator (strategic-default) 15:24 UTC
On it. Reading the Stripe config first...
```
@@ -253,12 +279,12 @@ Fleet Kill
```
Agent Fleet
βββββββββββ
- β¬’ coordinator coordinator claude-opus-4 :48391 online 13ms
+ β¬’ coordinator coordinator strategic-default :48391 online 13ms
- β¬’ coder implementation codex :48520 online 8ms
- β¬’ reviewer code-review codex :48540 online 9ms
- ⬑ deployer deployment codex :48560 unreachable
- β¬’ qa quality-assurance codex :48580 online 7ms
+ β¬’ coder implementation coding-default :48520 online 8ms
+ β¬’ reviewer code-review coding-default :48540 online 9ms
+ ⬑ deployer deployment coding-default :48560 unreachable
+ β¬’ qa quality-assurance coding-default :48580 online 7ms
```
#### `fleet audit`
@@ -326,39 +352,75 @@ Services
## Patterns
-Fleet supports any agent organization pattern. Three common ones:
+Fleet supports any agent organization pattern. Four common ones:
### Solo Empire
> One brain, many hands. The indie hacker setup.
```
- Coordinator (Opus)
+ Coordinator (strategic)
/ | \
Coder Reviewer Deployer
- (Codex) (Codex) (Codex)
+ (coding) (coding) (coding)
```
### Development Team
> Team leads managing specialized developers.
```
- Orchestrator (Opus)
+ Orchestrator (strategic)
/ | \
FE Lead BE Lead QA Lead
- (Sonnet) (Sonnet) (Sonnet)
+ (review) (review) (review)
/ \ | |
Dev1 Dev2 Dev1 Tester
- (Codex)(Codex)(Codex) (Codex)
+ (coding)(coding)(coding) (coding)
```
### Research Lab
> Specialized agents for knowledge work.
```
- Director (Opus)
+ Director (strategic)
/ | \ \
Scraper Analyst Writer Fact Check
- (Codex) (Sonnet) (Sonnet) (Codex)
+ (coding) (review) (review) (coding)
+```
+
+### Operator Constitution
+
+Fleet can optionally prepend a customizable operator constitution to every dispatched task. This is a prompt level guardrail for coding agents that read the task text. It does not replace the operating system, git, or CI protections.
+
+```json
+{
+ "constitution": {
+ "enabled": true,
+ "title": "Operator Constitution",
+ "mode": "prepend",
+ "required": true,
+ "applyTo": ["task", "parallel", "steer"],
+ "rules": [
+ "Read project instructions before editing files",
+ "Run verification before reporting completion",
+ "Do not rewrite shared git history unless the operator explicitly declares an emergency"
+ ]
+ }
+}
+```
+
+Use `fleet policy` to inspect rules, `fleet policy add "Run tests before reporting completion"` to customize them, `fleet policy require` to block dispatch without valid rules, `fleet policy scope task,parallel,steer` to choose coverage, and `fleet policy preview coder "fix login tests"` to see the exact prompt before dispatch.
+
+### Cross-Runtime Operation (v4)
+> Mix OpenClaw agents with Docker containers, HTTP services, and OS processes in one config.
+
+```
+ Coordinator
+ / | \
+ Coder Reviewer Deployer via OpenClaw adapter
+ |
+ billing-api (HTTP) via HTTP adapter
+ postgres (Docker) via Docker adapter
+ tailscaled (Process) via Process adapter
```
See [`docs/patterns.md`](docs/patterns.md) for detailed guides and [`examples/`](examples/) for configs.
@@ -375,8 +437,8 @@ Fleet reads `~/.fleet/config.json`. Create one with `fleet init` or manually:
"name": "coordinator"
},
"agents": [
- { "name": "coder", "port": 48520, "role": "implementation", "model": "codex" },
- { "name": "reviewer", "port": 48540, "role": "code review", "model": "codex" }
+ { "name": "coder", "port": 48520, "role": "implementation", "model": "coding-default" },
+ { "name": "reviewer", "port": 48540, "role": "code review", "model": "coding-default" }
],
"endpoints": [
{ "name": "website", "url": "https://myapp.com" },
@@ -391,6 +453,32 @@ Fleet reads `~/.fleet/config.json`. Create one with `fleet init` or manually:
Everything is configurable. No hardcoded ports, models, or names. Your fleet, your way.
+### v4: Cross-Runtime Adapters
+
+Register any target under the `runtimes` key. Each entry needs an `adapter` field:
+
+```json
+{
+ "runtimes": [
+ { "name": "billing-api", "adapter": "http", "url": "https://billing.example.com/health" },
+ { "name": "postgres", "adapter": "docker", "container": "postgres" },
+ { "name": "tailscaled", "adapter": "process", "process": "tailscaled" },
+ { "name": "secondary", "adapter": "openclaw", "port": 48490 }
+ ]
+}
+```
+
+Add a runtime from the CLI without editing JSON:
+
+```bash
+fleet runtime add billing-api http --url=https://billing.example.com/health
+fleet runtime add postgres docker --container=postgres
+fleet runtime add tailscale process --process=tailscaled
+fleet runtime test billing-api # one-off probe
+fleet runtime list # live status of all runtimes
+fleet adapters # list registered adapters and bindings
+```
+
See [`docs/configuration.md`](docs/configuration.md) for the full schema.
## Environment Variables
@@ -398,40 +486,57 @@ See [`docs/configuration.md`](docs/configuration.md) for the full schema.
| Variable | Description | Default |
|----------|-------------|---------|
| `FLEET_CONFIG` | Config file path | `~/.fleet/config.json` |
+| `FLEET_LOG` | Dispatch log path | `~/.fleet/log.jsonl` |
| `FLEET_WORKSPACE` | Workspace override | Config value |
| `FLEET_STATE_DIR` | State persistence | `~/.fleet/state` |
-| `NO_COLOR` | Disable colors | _(unset)_ |
+| `FLEET_ADAPTER_TIMEOUT` | Max seconds per adapter probe | `6` |
+| `FLEET_ADAPTERS_DIR` | Drop-in directory for custom adapters | `~/.fleet/adapters` |
+| `NO_COLOR` | Disable ANSI color output | _(unset)_ |
+| `FLEET_NO_UPDATE_CHECK` | Skip background GitHub release check | _(unset)_ |
## Architecture
```
fleet/
-βββ bin/fleet # Entry point
+βββ bin/fleet # Entry point
βββ lib/
-β βββ core/ # Config, output, state management
-β β βββ config.sh # JSON config loader
-β β βββ output.sh # Colors, formatting, HTTP helpers
-β β βββ state.sh # Delta state persistence
+β βββ core/ # Config, output, state, trust, adapter dispatcher
+β β βββ adapters.sh # v4 adapter registry, dispatcher, parallel probe helper
+β β βββ config.sh # JSON config loader
+β β βββ output.sh # Colors, formatting, HTTP helpers
+β β βββ state.sh # Delta state persistence
+β β βββ trust.sh # v3 trust scoring engine
+β βββ adapters/ # v4 cross-runtime adapters (one file per runtime type)
+β β βββ openclaw.sh # OpenClaw gateway probe (verified)
+β β βββ http.sh # Generic HTTP probe (verified)
+β β βββ docker.sh # Docker container state and health (verified when CLI present)
+β β βββ process.sh # OS process via pgrep (inferred)
β βββ commands/ # One file per command
+β βββ adapters.sh # v4: list adapters and bindings
β βββ agents.sh # Agent fleet status
β βββ audit.sh # Misconfiguration checker
β βββ backup.sh # Config backup/restore
β βββ ci.sh # GitHub CI integration
-β βββ health.sh # Endpoint health checks
+β βββ health.sh # Endpoint and runtime health checks
β βββ init.sh # Interactive setup
β βββ kill.sh # Graceful agent stop
β βββ log.sh # Append-only dispatch log
β βββ parallel.sh # Parallel task decomposition
+β βββ runtime.sh # v4: runtime add, test, list, rm
+β βββ score.sh # v3 score command
β βββ sitrep.sh # Structured status reports
β βββ skills.sh # ClawHub skill listing
β βββ steer.sh # Mid-session corrections
β βββ task.sh # Task dispatch to agents
+β βββ trust.sh # v3 trust command
+β βββ update.sh # Self-upgrade
β βββ watch.sh # Live session tail
βββ templates/configs/ # Config templates
βββ examples/ # Architecture pattern examples
β βββ solo-empire/
β βββ dev-team/
-β βββ research-lab/
+β βββ research-lab/
+β βββ cross-runtime/ # v4: mixed openclaw, http, docker, process targets
βββ docs/ # Documentation
βββ tests/ # Integration tests
βββ SKILL.md # ClawHub agent instructions
@@ -442,11 +547,11 @@ Modular by design. Each command is a separate file. Add your own by dropping a `
## For AI Agents
-Fleet ships with a [`SKILL.md`](SKILL.md) that any AI coding agent can read. Install it and your coordinator automatically knows how to manage the fleet:
+Fleet ships with a [`SKILL.md`](SKILL.md) that AI coding agents can read. It is designed for OpenClaw, Claude Code, Codex, Cursor, Windsurf, Gemini CLI, and custom agent stacks, and any shell capable coding agent or editor. Install it and your coordinator automatically knows how to manage the fleet:
```bash
clawhub install fleet # OpenClaw agents
-npx skills add oguzhnatly/fleet # Claude Code, Codex, Cursor, Windsurf, etc.
+npx skills add oguzhnatly/fleet # OpenClaw, Claude Code, Codex, Cursor, Windsurf, Gemini CLI, and shell capable agents
```
The agent reads the skill file, learns the commands, and runs health checks autonomously during heartbeat cycles.
@@ -489,15 +594,19 @@ Fleet learns which agents actually deliver, not just which ones are alive.
- [x] Trust summary line appended to every `fleet sitrep` output
- [x] v3.5: cross-validation: `fleet score` checks code/deploy successes against GitHub CI activity within 1h, flags unverified tasks
-### v4: Planned (cross-runtime adapter layer)
-Fleet works with any agent on any runtime, not just OpenClaw.
-
-- [ ] Pluggable adapter interface: three-function contract (health, info, version) every runtime implements
-- [ ] Built-in adapters: OpenClaw (verified), HTTP (any /health endpoint), Docker (container status), Process (inferred, labeled as such)
-- [ ] `fleet adapters`: list registered adapters, status, and whether health is verified or inferred
-- [ ] `fleet runtime add `: register a new runtime without editing config manually
-- [ ] `fleet runtime test `: one-off health check against a named adapter for debugging
-- [ ] Backward compatible: existing configs default to OpenClaw adapter, zero migration needed
+### v4: Shipped β
(cross-runtime adapter layer)
+OpenClaw remains the primary stack for Fleet, and v4 extends orchestration, monitoring, reliability judging, routing, and delta tracking across Claude Code, Codex, Cursor, Windsurf, Gemini CLI, and custom agent stacks.
+
+- [x] Pluggable adapter interface: six-function contract (`describe`, `verified`, `required`, `health`, `info`, `version`) every runtime implements
+- [x] Built-in adapters: OpenClaw (verified), HTTP (any /health endpoint, verified), Docker (container state and health, verified when CLI present), Process (pgrep-based, inferred and labeled as such)
+- [x] `fleet adapters`: lists registered adapters with verified vs inferred status, plus the binding from each agent and runtime entry to its adapter
+- [x] `fleet runtime add `: registers a new runtime without editing config manually
+- [x] `fleet runtime test `: one-off health, info, and version probe against a named adapter for debugging, with animated spinner
+- [x] `fleet runtime list` / `fleet runtime rm`: parallel probe of every runtime, and removal by name
+- [x] User adapter directory: drop a custom `.sh` into `~/.fleet/adapters/` (or `FLEET_ADAPTERS_DIR`) and it is auto-loaded
+- [x] `fleet agents`, `fleet health`, `fleet sitrep` now surface runtimes alongside agents, with delta tracking on runtime status changes
+- [x] Optional operator constitution: prepend customizable rules to task, parallel, and steer dispatches, preview them before execution, and optionally require valid rules before dispatch
+- [x] Backward compatible: existing configs default to OpenClaw adapter, zero migration needed
### v5: Planned (server mode and HTTP API)
Fleet becomes an embeddable data source, not just a CLI.
diff --git a/SKILL.md b/SKILL.md
index 9df4434..ef3492b 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -16,7 +16,7 @@ metadata:
kind: clawhub
slug: fleet
label: "Install fleet via ClawHub"
-description: "Multi-agent fleet management CLI for OpenClaw. Coordinator agent tool for monitoring, dispatching tasks to, and observing a fleet of agent gateways. Operations are local-only (loopback) plus explicitly declared external endpoints. Operator consent required before install."
+description: "Multi-agent fleet management for OpenClaw, Claude Code, Codex, Cursor, Windsurf, Gemini CLI, and custom agent stacks. One CLI to monitor, manage, and orchestrate your entire AI agent fleet. Fleet judges reliability, routes intelligently, tracks deltas, and keeps gateways, HTTP workers, Docker workers, and local processes accountable. Network access stays limited to loopback and explicitly configured endpoints. Operator consent required before install."
triggers: "check agents, fleet status, run sitrep, health check, dispatch task, send task to agent, steer agent, watch agent, parallel tasks, kill agent, fleet log, backup config, show agents, fleet report, how many agents online, CI status, what skills installed, trust score, which agent is reliable, fleet trust, fleet score, agent reliability, who should I assign, best agent for task"
requires:
binaries:
@@ -37,6 +37,10 @@ envVars:
description: "Override trust scoring window in hours (default: 72)"
- name: FLEET_NO_UPDATE_CHECK
description: "Set to 1 to disable background GitHub update check entirely"
+ - name: FLEET_ADAPTER_TIMEOUT
+ description: "Maximum seconds for a single adapter health or version probe (default: 6)"
+ - name: FLEET_ADAPTERS_DIR
+ description: "Directory for custom adapter scripts (default: ~/.fleet/adapters)"
- name: LINEAR_API_KEY
description: "Linear API key for CI ticket integration (optional, referenced in examples/solo-empire/config.json)"
- name: NO_COLOR
@@ -61,7 +65,7 @@ updateSpec:
backgroundCheck: "Once per 24h, non-blocking. Disable with FLEET_NO_UPDATE_CHECK=1"
permissions:
reads:
- - "~/.fleet/ (config, state, logs, backups created by fleet itself)"
+ - "~/.fleet/ (config, state, logs, backups, and custom adapters created or dropped by the operator)"
- "~/.openclaw/openclaw.json (read-only, fleet init only, workspace path detection)"
- "~/.openclaw*/agents/*/sessions/.jsonl (fleet watch: only fleet-named sessions)"
- "/proc/meminfo (Linux only, fleet sitrep)"
@@ -73,7 +77,7 @@ permissions:
- "127.0.0.1: (loopback only, operator config)"
- "api.github.com/repos//actions/runs (fleet ci, operator gh CLI auth)"
- "api.github.com/repos/oguzhnatly/fleet/releases/latest (background update check, 24h cache)"
- - "Operator-configured endpoint URLs (fleet health only)"
+ - "Operator-configured endpoint URLs (fleet health, runtime health, and runtime version probes only)"
sensitive:
storedPlaintext:
- "~/.fleet/config.json: agent gateway auth tokens"
@@ -97,7 +101,7 @@ agentScope:
## Intent, Authorization, and Trust
-**Who this skill is for:** A coordinator AI agent that has been explicitly configured by its operator (a human who set up and runs the OpenClaw fleet). The operator installed this skill intentionally, knowing it enables autonomous fleet management.
+**Who this skill is for:** A coordinator AI agent that has been explicitly configured by its operator. Fleet is designed for OpenClaw, Claude Code, Codex, Cursor, Windsurf, Gemini CLI, and custom agent stacks, and any shell capable coding agent or editor that can read a skill file and run commands. The operator installed this skill intentionally, knowing it enables autonomous fleet management.
**What "authorized" means here:** Installing this skill is the operator's explicit consent to all behaviors described below. The skill contacts only the operator's own agent gateways (loopback), the operator's own GitHub repos via their authenticated gh CLI, and api.github.com for fleet's own release updates (documented, non-blocking, once per 24h). It does not escalate privileges beyond the installing user's home directory and does not take any action outside the local fleet the operator already controls.
@@ -117,7 +121,7 @@ agentScope:
---
-CLI toolkit for managing, dispatching to, and observing a fleet of OpenClaw agent gateways. The coordinator agent uses fleet to monitor employees, dispatch tasks, steer running sessions, watch live output, and review dispatch history.
+Multi-agent fleet management for OpenClaw, Claude Code, Codex, Cursor, Windsurf, Gemini CLI, and custom agent stacks. One CLI to monitor, manage, and orchestrate your entire AI agent fleet. Fleet gives operators clear visibility into agent health, live work, reliability, runtime status, dispatch history, routing decisions, and delta tracking across gateways, HTTP workers, Docker workers, and local processes.
## Security Model
@@ -175,6 +179,53 @@ Fleet never calls `sudo`. Fleet never requests elevated permissions. All install
| User asks "what changed?" | `fleet sitrep`, report deltas |
| Scheduled morning report | `fleet sitrep 12` in cron |
| Before deploying | `fleet health` + `fleet ci` |
+| Register a non-OpenClaw target | `fleet runtime add [options]` (v4) |
+| Probe one runtime end to end | `fleet runtime test ` (v4) |
+| Live status of every runtime | `fleet runtime list` (v4) |
+| List adapters and their bindings | `fleet adapters` (v4) |
+| Show configured task rules | `fleet policy` |
+| Toggle task rules | `fleet policy enable`, `fleet policy disable` |
+| Require or relax rule enforcement | `fleet policy require`, `fleet policy optional` |
+| Add or remove a task rule | `fleet policy add ""`, `fleet policy rm ` |
+| Choose where rules apply | `fleet policy scope task,parallel,steer` |
+| Preview rules applied to a task | `fleet policy preview ""` |
+
+## Operator Constitution
+
+If `constitution.enabled` is true in the Fleet config, `fleet task`, `fleet parallel`, and `fleet steer` prepend the configured rules to dispatched agent messages. If `constitution.required` is true, scoped dispatches fail until valid rules are configured. Use this for operator rules that all agents should see before they act.
+
+Example config:
+
+```json
+{
+ "constitution": {
+ "enabled": true,
+ "title": "Operator Constitution",
+ "mode": "prepend",
+ "required": true,
+ "applyTo": ["task", "parallel", "steer"],
+ "rules": [
+ "Read project instructions before editing files",
+ "Run verification before reporting completion",
+ "Do not rewrite shared git history unless the operator explicitly declares an emergency"
+ ]
+ }
+}
+```
+
+Useful commands:
+
+```bash
+fleet policy
+fleet policy enable
+fleet policy require
+fleet policy add "Run verification before reporting completion"
+fleet policy scope task,parallel,steer
+fleet policy rm 1
+fleet policy preview coder "fix failing tests" --type code
+```
+
+Treat this as prompt level enforcement. It helps compliant coding agents follow the operator's rules, but it does not replace sandboxing, git hooks, CI, or human review.
## Auto-Setup
@@ -253,14 +304,14 @@ Create `~/.fleet/config.json`:
"port": 48391,
"name": "coordinator",
"role": "coordinator",
- "model": "claude-opus-4"
+ "model": "strategic-default"
},
"agents": [
{
"name": "coder",
"port": 48520,
"role": "implementation",
- "model": "codex",
+ "model": "coding-default",
"token": "your-agent-token"
}
],
@@ -529,12 +580,12 @@ Shows all configured agent gateways with live health status, response time, mode
```
Agent Fleet
βββββββββββ
- β¬’ coordinator coordinator claude-opus-4 :48391 online 13ms
+ β¬’ coordinator coordinator strategic-default :48391 online 13ms
- β¬’ coder implementation codex :48520 online 8ms
- β¬’ reviewer code-review codex :48540 online 9ms
- ⬑ deployer deployment codex :48560 unreachable
- β¬’ qa quality-assurance codex :48580 online 7ms
+ β¬’ coder implementation coding-default :48520 online 8ms
+ β¬’ reviewer code-review coding-default :48540 online 9ms
+ ⬑ deployer deployment coding-default :48560 unreachable
+ β¬’ qa quality-assurance coding-default :48580 online 7ms
```
**Status indicators:**
@@ -689,13 +740,72 @@ fleet update --force Reinstall even when already on latest
warning on stderr before its output:
```
-fleet v3.1.0 is available. Run fleet update to upgrade from v3.0.0.
+fleet v4.1.0 is available. Run fleet update to upgrade from v4.0.0.
```
The GitHub check runs as a detached background process once per 24 hours so there
is zero latency impact on normal fleet commands. The result is cached at
`~/.fleet/state/update_check.json`.
+### `fleet adapters`
+
+Lists every adapter the fleet knows about, marked **verified** when the adapter
+performs a real protocol-level handshake or **inferred** when it only detects
+presence. Also shows the binding from each agent and runtime entry to its
+adapter, so you can see at a glance which runtime probe runs against each
+target.
+
+### `fleet runtime add [options]`
+
+Registers a new entry under the `runtimes` key in `~/.fleet/config.json`
+without manual JSON editing.
+
+**Adapter types and required options:**
+
+| Type | Required | Common options |
+|------|----------|----------------|
+| `openclaw` | `--port=` | `--host`, `--token`, `--role`, `--model` |
+| `http` | `--url=` | `--expected-status`, `--method`, `--token`, `--header K=V`, `--version-url` |
+| `docker` | `--container=` | `--role`, `--model` |
+| `process` | `--process=` | `--match-full`, `--role` |
+
+Examples:
+
+```bash
+fleet runtime add billing-api http --url=https://billing.example.com/health
+fleet runtime add postgres docker --container=postgres
+fleet runtime add tailscale process --process=tailscaled
+fleet runtime add overflow openclaw --port=48490 --token=$OVERFLOW_TOKEN
+```
+
+The command refuses a name already used by an agent and writes the config
+atomically with `chmod 600`.
+
+### `fleet runtime test `
+
+One-off probe of a runtime or agent. Animates a spinner while the health,
+info, and version probes run in parallel, then renders all three sections.
+Use when a runtime turns red in `sitrep` and you need a focused look.
+
+### `fleet runtime list`
+
+Live status of every registered runtime, probed in parallel with an animated
+progress indicator on TTY output.
+
+### `fleet runtime rm `
+
+Removes a runtime from the config. Returns a clear error if the name is
+not found.
+
+### Custom Adapters
+
+Drop a `.sh` file into `~/.fleet/adapters/` (or `FLEET_ADAPTERS_DIR`)
+with these six functions: `adapter__describe`, `adapter__verified`
+(echo `verified` or `inferred`), `adapter__required`, `adapter__health`,
+`adapter__info`, `adapter__version`. Each must complete within
+`FLEET_ADAPTER_TIMEOUT` seconds (default 6) and emit JSON on stdout. The
+runtime registry validates required fields per adapter before saving.
+
## Fleet Patterns
Fleet supports multiple organizational architectures. Choose based on your needs:
@@ -704,20 +814,20 @@ Fleet supports multiple organizational architectures. Choose based on your needs
One coordinator, 2-5 employees. Best for indie hackers and solo founders.
```
- Coordinator (Opus)
+ Coordinator (strategic)
/ | \
Coder Reviewer Deployer
- (Codex) (Codex) (Codex)
+ (coding) (coding) (coding)
```
### Development Team
Team leads coordinating specialized developers. Best for complex products.
```
- Orchestrator (Opus)
+ Orchestrator (strategic)
/ | \
FE Lead BE Lead QA Lead
- (Sonnet) (Sonnet) (Sonnet)
+ (review) (review) (review)
/ \ | |
Dev1 Dev2 Dev1 Tester
```
@@ -726,7 +836,7 @@ Team leads coordinating specialized developers. Best for complex products.
Specialized agents for knowledge work. Best for content and analysis.
```
- Director (Opus)
+ Director (strategic)
/ | \ \
Scraper Analyst Writer Fact-Check
```
diff --git a/_meta.json b/_meta.json
index c1dfb89..c066cdb 100644
--- a/_meta.json
+++ b/_meta.json
@@ -1,8 +1,8 @@
{
"slug": "fleet",
- "version": "3.0.5",
+ "version": "4.0.0",
"type": "installable-cli",
- "description": "Multi-agent fleet management CLI for OpenClaw. Coordinator agent tool for monitoring, dispatching tasks to, and observing a fleet of agent gateways. All operations are local (loopback only). Operator consent is required before install.",
+ "description": "Multi-agent fleet management for OpenClaw, Claude Code, Codex, Cursor, Windsurf, Gemini CLI, and custom agent stacks. One CLI to monitor, manage, and orchestrate your entire AI agent fleet. Fleet judges reliability, routes intelligently, tracks deltas, and keeps gateways, HTTP endpoints, Docker workers, and OS processes accountable. Network access stays limited to loopback and explicitly configured endpoints. Operator consent is required before install.",
"requires": {
"binaries": [
"bash",
@@ -63,13 +63,22 @@
{
"name": "FLEET_NO_UPDATE_CHECK",
"description": "Set to any value to disable the background GitHub release check and update banner"
+ },
+ {
+ "name": "FLEET_ADAPTER_TIMEOUT",
+ "description": "Maximum seconds any single adapter health probe may take before the dispatcher returns an error result (default: 6)"
+ },
+ {
+ "name": "FLEET_ADAPTERS_DIR",
+ "description": "Override the user adapter drop in directory (default: ~/.fleet/adapters). Custom .sh files placed here are auto loaded alongside the built in adapters."
}
]
},
"permissions": {
"reads": [
- "~/.fleet/config.json (agent gateway config created by fleet init)",
+ "~/.fleet/config.json (agent gateway and runtime config created by fleet init)",
"~/.fleet/state/ (delta tracking state created by fleet itself)",
+ "~/.fleet/adapters/ (operator dropped custom adapter scripts loaded by the v4 adapter dispatcher)",
"~/.openclaw*/agents/main/sessions/.jsonl (active output stream of the operator's own fleet-named sessions only, for fleet watch command)",
"~/.openclaw*/openclaw.json (gateway config, read-only, only during fleet init auto-detection)",
"/proc/meminfo (memory usage, for fleet sitrep resource section, Linux only)"
@@ -108,4 +117,4 @@
"consent": "Installing this skill is the operator's explicit consent to the init behavior described above. If you do not want automatic writes to your home directory or shell rc modification, clone the repo and configure ~/.fleet/config.json manually without running fleet init."
},
"installable": true
-}
\ No newline at end of file
+}
diff --git a/assets/banner.svg b/assets/banner.svg
index d077195..72e8fb9 100644
--- a/assets/banner.svg
+++ b/assets/banner.svg
@@ -174,12 +174,15 @@
fleet
- Multi-agent fleet management for OpenClaw
+ Multi-agent fleet management
+ OpenClaw, Claude Code, Codex
+ Cursor, Windsurf, Gemini CLI, custom stacks
+ One CLI to monitor, manage, and orchestrate your AI agent fleet
-
-
+
+
- v3.0.0
+ v4.0.0
MIT
diff --git a/assets/demo.gif b/assets/demo.gif
index 5dd07a4..ebdc6a0 100644
Binary files a/assets/demo.gif and b/assets/demo.gif differ
diff --git a/assets/social-preview.png b/assets/social-preview.png
index 3cfad52..b54d303 100644
Binary files a/assets/social-preview.png and b/assets/social-preview.png differ
diff --git a/bin/fleet b/bin/fleet
index 37cea47..2f5aff2 100755
--- a/bin/fleet
+++ b/bin/fleet
@@ -29,6 +29,9 @@ source "$FLEET_ROOT/lib/core/config.sh"
source "$FLEET_ROOT/lib/core/output.sh"
source "$FLEET_ROOT/lib/core/state.sh"
source "$FLEET_ROOT/lib/core/trust.sh"
+source "$FLEET_ROOT/lib/core/adapters.sh"
+source "$FLEET_ROOT/lib/core/policy.sh"
+export FLEET_ROOT
# ββ Load commands βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
for cmd_file in "$FLEET_ROOT/lib/commands/"*.sh; do
@@ -112,6 +115,7 @@ cmd_help() {
fleet parallel "" Decompose and dispatch in parallel (trust-weighted)
fleet kill Send graceful stop signal
fleet log [--agent X] [--limit N] View dispatch history
+ fleet policy Show operator constitution rules
MONITORING
fleet health Health check all gateways and endpoints
@@ -124,6 +128,13 @@ cmd_help() {
fleet score [] Per-task-type reliability breakdown
fleet score --type code Filter score to one task type
+ CROSS RUNTIME (v4)
+ fleet adapters List adapters and bindings
+ fleet runtime add Register a runtime (openclaw|http|docker|process)
+ fleet runtime test One off probe of a runtime or agent
+ fleet runtime list List runtimes with live status
+ fleet runtime rm Remove a runtime
+
DEVELOPMENT
fleet ci [filter] GitHub CI status across repos
fleet skills List installed ClawHub skills
@@ -167,6 +178,9 @@ case "${1:-help}" in
init|setup) cmd_init ;;
trust) cmd_trust "${@:2}" ;;
score) cmd_score "${@:2}" ;;
+ adapters) cmd_adapters ;;
+ runtime|runtimes) cmd_runtime "${@:2}" ;;
+ policy|constitution) cmd_policy "${@:2}" ;;
update) cmd_update "${@:2}" ;;
help|--help|-h) cmd_help ;;
*)
diff --git a/docs/configuration.md b/docs/configuration.md
index ef88ba7..65b8a13 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -23,6 +23,33 @@ Fleet reads from `~/.fleet/config.json` by default. Override with `FLEET_CONFIG`
"token": "string: auth token for HTTP API"
}
],
+ "constitution": {
+ "enabled": "boolean: prepend operator rules to dispatched tasks",
+ "title": "string: display title for the rule block",
+ "mode": "string: prepend or append",
+ "required": "boolean: block scoped dispatches when the constitution is disabled or empty",
+ "agents": "array: optional list of agent names to apply to",
+ "applyTo": "array: task, parallel, steer, or all",
+ "rules": "array: ordered rule strings"
+ },
+ "runtimes": [
+ {
+ "name": "string: unique runtime name",
+ "adapter": "string: openclaw, http, docker, process, or a custom adapter type",
+ "role": "string: optional display role",
+ "model": "string: optional display label",
+ "host": "string: host for openclaw adapter (default: 127.0.0.1)",
+ "port": "number: port for openclaw adapter",
+ "url": "string: health URL for http adapter",
+ "versionUrl": "string: optional version endpoint for http adapter",
+ "expectedStatus": "number: expected HTTP status for http adapter (default: 200)",
+ "method": "string: HTTP method for http adapter (default: GET)",
+ "headers": "object: extra HTTP headers for http adapter",
+ "container": "string: Docker container name or id for docker adapter",
+ "process": "string: process name or pattern for process adapter",
+ "matchFull": "boolean: use full command matching for process adapter"
+ }
+ ],
"endpoints": [
{
"name": "string: display name",
@@ -56,6 +83,8 @@ Fleet reads from `~/.fleet/config.json` by default. Override with `FLEET_CONFIG`
| `FLEET_LOG` | Path to dispatch log file | `~/.fleet/log.jsonl` |
| `FLEET_WORKSPACE` | Override workspace path | Config value |
| `FLEET_STATE_DIR` | State persistence directory | `~/.fleet/state` |
+| `FLEET_ADAPTER_TIMEOUT` | Maximum seconds for a single adapter health probe | `6` |
+| `FLEET_ADAPTERS_DIR` | Directory for custom adapter scripts | `~/.fleet/adapters` |
| `NO_COLOR` | Disable colored output | (unset) |
## Auto-Detection
@@ -72,6 +101,82 @@ See the `examples/` directory for recommended configurations:
- **dev-team**: Team leads with specialized developers
- **research-lab**: Research director with analysts and writers
+## Operator Constitution
+
+The optional `constitution` block prepends rules to prompts sent by `fleet task` and `fleet parallel`.
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `enabled` | boolean | `false` | Turn prompt policy injection on or off |
+| `title` | string | `Operator Constitution` | Header for the injected block |
+| `mode` | string | `prepend` | Use `prepend` or `append` |
+| `required` | boolean | `false` | Block scoped dispatches when the constitution is disabled or empty |
+| `agents` | array | all agents | Optional allow list of agent names |
+| `applyTo` | array | `task`, `parallel`, `steer` | Commands that receive the rule block |
+| `rules` | array | empty | Ordered rule strings sent with each task |
+
+Example:
+
+```json
+{
+ "constitution": {
+ "enabled": true,
+ "required": true,
+ "applyTo": ["task", "parallel", "steer"],
+ "rules": [
+ "Read project instructions before editing files",
+ "Run verification before reporting completion",
+ "Do not rewrite shared git history unless the operator explicitly declares an emergency"
+ ]
+ }
+}
+```
+
+Use `fleet policy` to inspect the effective config, `fleet policy add ""` to customize it from the CLI, `fleet policy require` to block scoped dispatches without valid rules, `fleet policy scope task,parallel,steer` to choose where it applies, and `fleet policy preview ""` to preview the dispatch text.
+
+## Runtime Configuration (v4)
+
+Fleet v4 adds a `runtimes` array for non coordinator targets. This is additive. Existing v1, v2, and v3 configs continue to work without a migration.
+
+### Adapter fields
+
+| Adapter | Required fields | Verification mode |
+|---------|-----------------|-------------------|
+| `openclaw` | `port` | verified through `/health` or authenticated `/v1/models` fallback |
+| `http` | `url` | verified through configured HTTP response status |
+| `docker` | `container` | verified through Docker daemon inspection when the Docker CLI is available |
+| `process` | `process` | inferred through `pgrep`, no protocol handshake |
+
+### Example
+
+```json
+{
+ "runtimes": [
+ {
+ "name": "billing-api",
+ "adapter": "http",
+ "url": "http://127.0.0.1:8080/health",
+ "versionUrl": "http://127.0.0.1:8080/version",
+ "role": "api"
+ },
+ {
+ "name": "worker",
+ "adapter": "docker",
+ "container": "worker-service"
+ },
+ {
+ "name": "scheduler",
+ "adapter": "process",
+ "process": "cron"
+ }
+ ]
+}
+```
+
+### Custom adapters
+
+Drop a `.sh` file into `~/.fleet/adapters/` or set `FLEET_ADAPTERS_DIR`. A custom adapter must define six functions named `adapter__describe`, `adapter__verified`, `adapter__required`, `adapter__health`, `adapter__info`, and `adapter__version`.
+
## Trust Configuration (v3)
The `trust` block configures the v3 reliability scoring engine.
diff --git a/docs/patterns.md b/docs/patterns.md
index e6d2842..8db9980 100644
--- a/docs/patterns.md
+++ b/docs/patterns.md
@@ -14,7 +14,7 @@ The simplest and most common pattern. One coordinator makes all decisions, 1-5 e
Coder Reviewer Deployer
```
-**Key insight:** Use the strongest model (Opus) for strategic decisions, cheapest model (Codex) for execution. This maximizes quality while controlling cost.
+**Key insight:** Use the strongest model (strategic) for strategic decisions, cheapest model (coding) for execution. This maximizes quality while controlling cost.
## Development Team
@@ -30,7 +30,7 @@ Team leads coordinate specialized developers. The orchestrator only talks to lea
Dev1 Dev2 Dev1 Tester
```
-**Key insight:** Model hierarchy matters. Leads should be Sonnet-class (good at architecture and review), developers should be Codex-class (fast at implementation).
+**Key insight:** Model hierarchy matters. Leads should be review class (good at architecture and review), developers should be coding class (fast at implementation).
## Research Lab
@@ -70,7 +70,7 @@ Temporary teams assembled for specific projects, then disbanded. The coordinator
| Complexity | Low | High | Medium | Medium |
| Cost | Low | High | Medium | Variable |
| Always-on agents | 2-5 | 5-15 | 3-6 | 0-1 |
-| Best model mix | Opus+Codex | Opus+Sonnet+Codex | Opus+Sonnet+Codex | Opus+Codex |
+| Best model mix | strategic and coding | strategic, review, and coding | strategic, review, and coding | strategic and coding |
| Setup time | 5 min | 30 min | 15 min | 10 min |
## Scaling
diff --git a/examples/cross-runtime/README.md b/examples/cross-runtime/README.md
new file mode 100644
index 0000000..404925f
--- /dev/null
+++ b/examples/cross-runtime/README.md
@@ -0,0 +1,34 @@
+# Cross Runtime Example
+
+This example shows one Fleet config monitoring four runtime styles through the v4 adapter layer.
+
+## Included targets
+
+| Name | Adapter | What it probes |
+|------|---------|----------------|
+| `local-openclaw` | `openclaw` | Local OpenClaw gateway health and authenticated model endpoint fallback |
+| `billing-api` | `http` | Any operator configured HTTP health endpoint |
+| `worker-container` | `docker` | Docker container state and embedded health status when present |
+| `scheduler` | `process` | OS process presence through `pgrep`, labeled inferred |
+
+## Try it safely
+
+```bash
+FLEET_CONFIG=examples/cross-runtime/config.json fleet adapters
+FLEET_CONFIG=examples/cross-runtime/config.json fleet runtime list
+FLEET_CONFIG=examples/cross-runtime/config.json fleet runtime test billing-api
+```
+
+The example uses placeholder ports, URLs, container names, and process names. Update them to your local targets before expecting green health checks.
+
+## Add your own runtime
+
+```bash
+fleet runtime add search-api http --url http://127.0.0.1:8080/health --version-url http://127.0.0.1:8080/version
+fleet runtime add queue docker --container redis
+fleet runtime add scheduler process --process cron
+```
+
+## Expected output shape
+
+`fleet runtime list` renders one row per runtime with adapter type, endpoint, status, verification mode, and latency. Verified adapters perform a protocol or daemon handshake. Inferred adapters detect presence without a protocol handshake.
diff --git a/examples/cross-runtime/config.json b/examples/cross-runtime/config.json
new file mode 100644
index 0000000..e6401e1
--- /dev/null
+++ b/examples/cross-runtime/config.json
@@ -0,0 +1,76 @@
+{
+ "workspace": "~/workspace",
+ "gateway": {
+ "port": 48391,
+ "name": "coordinator",
+ "role": "coordinator",
+ "model": "default"
+ },
+ "agents": [
+ {
+ "name": "coder",
+ "port": 48520,
+ "role": "implementation",
+ "model": "coding-default",
+ "token": "your-token-here"
+ }
+ ],
+ "runtimes": [
+ {
+ "name": "billing-api",
+ "adapter": "http",
+ "url": "https://billing.example.com/health",
+ "expectedStatus": 200,
+ "role": "external-service"
+ },
+ {
+ "name": "auth-service",
+ "adapter": "http",
+ "url": "https://auth.example.com/healthz",
+ "expectedStatus": 200,
+ "token": "your-auth-probe-token",
+ "role": "external-service"
+ },
+ {
+ "name": "postgres",
+ "adapter": "docker",
+ "container": "postgres",
+ "role": "database"
+ },
+ {
+ "name": "redis",
+ "adapter": "docker",
+ "container": "redis",
+ "role": "cache"
+ },
+ {
+ "name": "tailscaled",
+ "adapter": "process",
+ "process": "tailscaled",
+ "role": "networking"
+ },
+ {
+ "name": "caddy",
+ "adapter": "process",
+ "process": "caddy",
+ "role": "reverse-proxy"
+ },
+ {
+ "name": "secondary-node",
+ "adapter": "openclaw",
+ "port": 48490,
+ "role": "overflow-agent"
+ }
+ ],
+ "endpoints": [
+ { "name": "production", "url": "https://myapp.example.com", "expectedStatus": 200 },
+ { "name": "api-docs", "url": "https://docs.example.com", "expectedStatus": 200 }
+ ],
+ "repos": [
+ { "name": "backend", "repo": "myorg/backend" },
+ { "name": "infra", "repo": "myorg/infrastructure" }
+ ],
+ "trust": {
+ "windowHours": 72
+ }
+}
diff --git a/examples/dev-team/README.md b/examples/dev-team/README.md
index 744aec2..9bff07d 100644
--- a/examples/dev-team/README.md
+++ b/examples/dev-team/README.md
@@ -4,23 +4,23 @@
```
βββββββββββββββββββββββββββββββββββββββββββββββ
-β Orchestrator (Opus) β
+β Orchestrator (strategic) β
β Strategic planning, cross-team sync β
βββββββββββββββββ¬βββββββββββββββ¬βββββββββββββββ€
β FE Lead β BE Lead β QA Lead β
-β (Sonnet) β (Sonnet) β (Sonnet) β
+β (review) β (review) β (review) β
β Architecture β API design β Test plans β
βββββββββ¬ββββββββΌβββββββββββββββΌβββββββββββββββ€
β Dev 1 β Dev 2 β Dev 1 β Tester β
-β(Codex)β(Codex)β (Codex) β (Codex) β
+β(coding)β(coding)β (coding) β (coding) β
βββββββββ΄ββββββββ΄βββββββββββββββ΄βββββββββββββββ
```
## How It Works
1. **Orchestrator** breaks projects into team-level tasks
-2. **Team Leads** (Sonnet) decompose tasks and coordinate their teams
-3. **Developers** (Codex) implement features
+2. **Team Leads** (review) decompose tasks and coordinate their teams
+3. **Developers** (coding) implement features
4. **QA** validates across the full stack
5. Leads report status back to Orchestrator
@@ -34,6 +34,6 @@
## Key Concept: Model Hierarchy
Use stronger models for decisions, cheaper models for execution:
-- **Orchestrator**: Opus (strategic, expensive but precise)
-- **Team Leads**: Sonnet (architectural decisions, reviews)
-- **Developers**: Codex (fast implementation, high throughput)
+- **Orchestrator**: strategic model (strategic, expensive but precise)
+- **Team Leads**: review model (architectural decisions, reviews)
+- **Developers**: coding model (fast implementation, high throughput)
diff --git a/examples/dev-team/config.json b/examples/dev-team/config.json
index d51d9b3..38f7bfd 100644
--- a/examples/dev-team/config.json
+++ b/examples/dev-team/config.json
@@ -4,56 +4,56 @@
"port": 48391,
"name": "orchestrator",
"role": "coordinator",
- "model": "claude-opus-4"
+ "model": "strategic-default"
},
"agents": [
{
"name": "frontend-lead",
"port": 48420,
"role": "frontend-team-lead",
- "model": "claude-sonnet-4",
+ "model": "review-default",
"token": "emp-fe-lead-token"
},
{
"name": "frontend-dev-1",
"port": 48440,
"role": "frontend-developer",
- "model": "codex",
+ "model": "coding-default",
"token": "emp-fe-dev1-token"
},
{
"name": "frontend-dev-2",
"port": 48460,
"role": "frontend-developer",
- "model": "codex",
+ "model": "coding-default",
"token": "emp-fe-dev2-token"
},
{
"name": "backend-lead",
"port": 48520,
"role": "backend-team-lead",
- "model": "claude-sonnet-4",
+ "model": "review-default",
"token": "emp-be-lead-token"
},
{
"name": "backend-dev-1",
"port": 48540,
"role": "backend-developer",
- "model": "codex",
+ "model": "coding-default",
"token": "emp-be-dev1-token"
},
{
"name": "qa-lead",
"port": 48620,
"role": "qa-manager",
- "model": "claude-sonnet-4",
+ "model": "review-default",
"token": "emp-qa-lead-token"
},
{
"name": "qa-tester",
"port": 48640,
"role": "qa-tester",
- "model": "codex",
+ "model": "coding-default",
"token": "emp-qa-tester-token"
}
],
diff --git a/examples/research-lab/README.md b/examples/research-lab/README.md
index 1db0e8e..b7d336f 100644
--- a/examples/research-lab/README.md
+++ b/examples/research-lab/README.md
@@ -4,12 +4,12 @@
```
ββββββββββββββββββββββββββββββββββββββββββ
-β Director (Opus) β
+β Director (strategic) β
β Research planning, synthesis β
ββββββββββββ¬βββββββββββ¬βββββββββ¬βββββββββ€
β Analyst β Writer βScraper β Fact β
-β (Sonnet) β (Sonnet) β(Codex) β Check β
-β Data β Reports β Web β(Codex) β
+β (review) β (review) β(coding) β Check β
+β Data β Reports β Web β(coding) β
ββββββββββββ΄βββββββββββ΄βββββββββ΄βββββββββ
```
diff --git a/examples/research-lab/config.json b/examples/research-lab/config.json
index 4f780b1..d036ac0 100644
--- a/examples/research-lab/config.json
+++ b/examples/research-lab/config.json
@@ -4,35 +4,35 @@
"port": 48391,
"name": "director",
"role": "research-director",
- "model": "claude-opus-4"
+ "model": "strategic-default"
},
"agents": [
{
"name": "analyst",
"port": 48420,
"role": "data-analyst",
- "model": "claude-sonnet-4",
+ "model": "review-default",
"token": "emp-analyst-token"
},
{
"name": "writer",
"port": 48440,
"role": "technical-writer",
- "model": "claude-sonnet-4",
+ "model": "review-default",
"token": "emp-writer-token"
},
{
"name": "scraper",
"port": 48460,
"role": "web-scraping",
- "model": "codex",
+ "model": "coding-default",
"token": "emp-scraper-token"
},
{
"name": "fact-checker",
"port": 48480,
"role": "verification",
- "model": "codex",
+ "model": "coding-default",
"token": "emp-factcheck-token"
}
],
diff --git a/examples/solo-empire/README.md b/examples/solo-empire/README.md
index 18abd4e..da98dcb 100644
--- a/examples/solo-empire/README.md
+++ b/examples/solo-empire/README.md
@@ -4,11 +4,11 @@
```
ββββββββββββββββββββββββββββββββ
-β Coordinator (Opus) β
+β Coordinator (strategic) β
β Strategic decisions, plans β
ββββββββββββββββ¬ββββββββββββββββ€
β Coder β Reviewer β
-β (Codex) β (Codex) β
+β (coding) β (coding) β
β Write code β Review PRs β
ββββββββββββββββ΄ββββββββββββββββ
```
diff --git a/examples/solo-empire/config.json b/examples/solo-empire/config.json
index 26f2348..c47c1e2 100644
--- a/examples/solo-empire/config.json
+++ b/examples/solo-empire/config.json
@@ -4,21 +4,21 @@
"port": 48391,
"name": "brain",
"role": "coordinator",
- "model": "claude-opus-4"
+ "model": "strategic-default"
},
"agents": [
{
"name": "coder",
"port": 48520,
"role": "implementation",
- "model": "codex",
+ "model": "coding-default",
"token": "emp-coder-token"
},
{
"name": "reviewer",
"port": 48540,
"role": "code-review",
- "model": "codex",
+ "model": "coding-default",
"token": "emp-reviewer-token"
}
],
diff --git a/lib/adapters/docker.sh b/lib/adapters/docker.sh
new file mode 100644
index 0000000..6e6b9e8
--- /dev/null
+++ b/lib/adapters/docker.sh
@@ -0,0 +1,118 @@
+#!/bin/bash
+# fleet/lib/adapters/docker.sh: Docker container adapter.
+# Verified: queries the local docker daemon for container state and the
+# embedded health check, when one is configured. If the docker CLI is
+# missing, the adapter degrades gracefully and reports verified false.
+
+adapter_docker_describe() {
+ echo "Docker container state and health (verified when docker CLI present)"
+}
+
+adapter_docker_verified() { echo "verified"; }
+
+adapter_docker_required() { echo "container"; }
+
+adapter_docker_health() {
+ local entry_json="$1"
+ python3 - "$entry_json" "${FLEET_ADAPTER_TIMEOUT:-6}" <<'HEALTH_PY'
+import json, shutil, subprocess, sys, time
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ e = {}
+timeout = sys.argv[2]
+container = e.get("container","")
+if not container:
+ print(json.dumps({"status":"error","code":"","elapsed_ms":0,"verified":False,"message":"container required"}))
+ sys.exit(0)
+if not shutil.which("docker"):
+ print(json.dumps({"status":"unknown","code":"","elapsed_ms":0,"verified":False,"message":"docker CLI not installed"}))
+ sys.exit(0)
+start = time.time()
+try:
+ r = subprocess.run(
+ ["docker","inspect","--format",
+ "{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}|{{.State.ExitCode}}",
+ container],
+ capture_output=True, text=True, timeout=int(timeout)
+ )
+except subprocess.TimeoutExpired:
+ print(json.dumps({"status":"error","code":"","elapsed_ms":int((time.time()-start)*1000),"verified":False,"message":"docker inspect timed out"}))
+ sys.exit(0)
+ms = int((time.time()-start)*1000)
+if r.returncode != 0:
+ msg = (r.stderr or "").strip().splitlines()[0:1]
+ msg = msg[0] if msg else "container not found"
+ print(json.dumps({"status":"unreachable","code":"","elapsed_ms":ms,"verified":True,"message":msg[:160]}))
+ sys.exit(0)
+parts = (r.stdout or "").strip().split("|")
+state = parts[0] if len(parts) > 0 else ""
+health = parts[1] if len(parts) > 1 else "none"
+exit_code = parts[2] if len(parts) > 2 else "0"
+if health == "healthy":
+ status = "online"
+elif health == "starting":
+ status = "starting"
+elif health == "unhealthy":
+ status = "degraded"
+elif state == "running":
+ status = "online"
+elif state in ("exited","dead","created"):
+ status = "offline"
+elif state == "paused":
+ status = "degraded"
+elif state == "restarting":
+ status = "starting"
+else:
+ status = state or "unknown"
+print(json.dumps({"status":status,"code":state,"elapsed_ms":ms,"verified":True,"message":f"health:{health} exit:{exit_code}"}))
+HEALTH_PY
+}
+
+adapter_docker_info() {
+ local entry_json="$1"
+ python3 - "$entry_json" <<'INFO_PY'
+import json, sys
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ e = {}
+print(json.dumps({
+ "name": e.get("name",""),
+ "type": "docker",
+ "endpoint": f"docker://{e.get('container','')}",
+ "model": e.get("model",""),
+ "role": e.get("role",""),
+ "verified": True
+}))
+INFO_PY
+}
+
+adapter_docker_version() {
+ local entry_json="$1"
+ python3 - "$entry_json" "${FLEET_ADAPTER_TIMEOUT:-6}" <<'VERSION_PY'
+import json, shutil, subprocess, sys
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ e = {}
+container = e.get("container","")
+if not container or not shutil.which("docker"):
+ print(json.dumps({"version":"","verified":False}))
+ sys.exit(0)
+timeout = sys.argv[2]
+try:
+ r = subprocess.run(
+ ["docker","inspect","--format","{{.Config.Image}}",container],
+ capture_output=True, text=True, timeout=int(timeout)
+ )
+ image = (r.stdout or "").strip()
+ if r.returncode != 0 or not image:
+ print(json.dumps({"version":"","verified":False}))
+ sys.exit(0)
+ ver = image.split(":")[-1] if ":" in image else "latest"
+ print(json.dumps({"version":ver,"verified":True}))
+except Exception:
+ print(json.dumps({"version":"","verified":False}))
+VERSION_PY
+}
diff --git a/lib/adapters/http.sh b/lib/adapters/http.sh
new file mode 100644
index 0000000..7020d11
--- /dev/null
+++ b/lib/adapters/http.sh
@@ -0,0 +1,108 @@
+#!/bin/bash
+# fleet/lib/adapters/http.sh: Generic HTTP adapter.
+# Verified: any service that exposes a URL and returns an expected status.
+# Use it for vendor APIs, internal services, and OpenClaw nodes that already
+# have a custom URL.
+
+adapter_http_describe() {
+ echo "Generic HTTP probe with expected status (verified)"
+}
+
+adapter_http_verified() { echo "verified"; }
+
+adapter_http_required() { echo "url"; }
+
+adapter_http_health() {
+ local entry_json="$1"
+ python3 - "$entry_json" "${FLEET_ADAPTER_TIMEOUT:-6}" <<'HEALTH_PY'
+import json, subprocess, sys, time
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ e = {}
+timeout = sys.argv[2]
+url = e.get("url","")
+expected = str(e.get("expectedStatus", 200))
+method = e.get("method","GET").upper()
+token = e.get("token","")
+extra_headers = e.get("headers", {}) or {}
+if not url:
+ print(json.dumps({"status":"error","code":"","elapsed_ms":0,"verified":False,"message":"url required"}))
+ sys.exit(0)
+args = ["curl","-s","-o","/dev/null","-w","%{http_code}","-X",method,"--max-time",str(timeout)]
+if token:
+ args += ["-H", f"Authorization: Bearer {token}"]
+for k, v in extra_headers.items():
+ args += ["-H", f"{k}: {v}"]
+args.append(url)
+start = time.time()
+try:
+ r = subprocess.run(args, capture_output=True, text=True)
+ code = (r.stdout or "").strip()
+except Exception as ex:
+ print(json.dumps({"status":"error","code":"","elapsed_ms":0,"verified":False,"message":str(ex)[:120]}))
+ sys.exit(0)
+ms = int((time.time()-start)*1000)
+if code == expected:
+ status = "online"
+elif code == "000":
+ status = "unreachable"
+elif code in ("301","302","307","308") and not expected.startswith(("3","4","5")):
+ status = "online"
+elif code in ("401","403"):
+ status = "auth_failed"
+else:
+ status = "offline"
+verified = code not in ("","000")
+print(json.dumps({"status":status,"code":code,"elapsed_ms":ms,"verified":verified,"message":""}))
+HEALTH_PY
+}
+
+adapter_http_info() {
+ local entry_json="$1"
+ python3 - "$entry_json" <<'INFO_PY'
+import json, sys
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ e = {}
+print(json.dumps({
+ "name": e.get("name",""),
+ "type": "http",
+ "endpoint": e.get("url",""),
+ "model": e.get("model",""),
+ "role": e.get("role",""),
+ "verified": True
+}))
+INFO_PY
+}
+
+adapter_http_version() {
+ local entry_json="$1"
+ python3 - "$entry_json" "${FLEET_ADAPTER_TIMEOUT:-6}" <<'VERSION_PY'
+import json, subprocess, sys
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ e = {}
+url = e.get("versionUrl") or e.get("url","")
+if not url:
+ print(json.dumps({"version":"","verified":False}))
+ sys.exit(0)
+timeout = sys.argv[2]
+try:
+ r = subprocess.run(["curl","-s","--max-time",str(timeout),url],
+ capture_output=True, text=True)
+ body = (r.stdout or "").strip()
+ ver = ""
+ if body:
+ try:
+ data = json.loads(body)
+ ver = data.get("version","") or data.get("build","") or ""
+ except Exception:
+ ver = body.splitlines()[0][:64]
+ print(json.dumps({"version":ver,"verified":bool(ver)}))
+except Exception:
+ print(json.dumps({"version":"","verified":False}))
+VERSION_PY
+}
diff --git a/lib/adapters/openclaw.sh b/lib/adapters/openclaw.sh
new file mode 100644
index 0000000..347cd98
--- /dev/null
+++ b/lib/adapters/openclaw.sh
@@ -0,0 +1,124 @@
+#!/bin/bash
+# fleet/lib/adapters/openclaw.sh: OpenClaw gateway adapter.
+# Verified: probes /health on 127.0.0.1:, falls back to authenticated
+# /v1/models when /health returns non 200 and a token is present. This is the
+# default adapter and preserves v3 behavior exactly.
+
+adapter_openclaw_describe() {
+ echo "OpenClaw gateway via /health and /v1/models (verified)"
+}
+
+adapter_openclaw_verified() { echo "verified"; }
+
+adapter_openclaw_required() { echo "port"; }
+
+_openclaw_probe() {
+ # Args: url, timeout, optional bearer token
+ # Echo: "code elapsed_ms"
+ local url="$1" timeout="$2" token="${3:-}"
+ local start end code
+ start=$(python3 -c "import time; print(int(time.time()*1e9))")
+ if [ -n "$token" ]; then
+ code=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$timeout" \
+ -H "Authorization: Bearer $token" "$url" 2>/dev/null)
+ else
+ code=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$timeout" "$url" 2>/dev/null)
+ fi
+ end=$(python3 -c "import time; print(int(time.time()*1e9))")
+ echo "$code $(( (end - start) / 1000000 ))"
+}
+
+adapter_openclaw_health() {
+ local entry_json="$1"
+ python3 - "$entry_json" "${FLEET_ADAPTER_TIMEOUT:-6}" <<'HEALTH_PY'
+import json, subprocess, sys, time
+e = {}
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ pass
+timeout = sys.argv[2]
+host = e.get("host", "127.0.0.1")
+port = e.get("port")
+token = e.get("token", "")
+if not port:
+ print(json.dumps({"status":"error","code":"","elapsed_ms":0,"verified":False,"message":"port required"}))
+ sys.exit(0)
+url = f"http://{host}:{port}/health"
+def probe(url, headers=None):
+ start = time.time()
+ args = ["curl","-s","-o","/dev/null","-w","%{http_code}","--max-time",str(timeout),url]
+ if headers:
+ for h in headers:
+ args.extend(["-H", h])
+ r = subprocess.run(args, capture_output=True, text=True)
+ return r.stdout.strip(), int((time.time()-start)*1000)
+code, ms = probe(url)
+auth_used = False
+if code != "200" and token:
+ auth_url = f"http://{host}:{port}/v1/models"
+ code2, ms2 = probe(auth_url, headers=[f"Authorization: Bearer {token}"])
+ if code2 in ("200","401"):
+ code, ms = code2, ms2
+ auth_used = True
+status = "online" if code == "200" else ("auth_failed" if code == "401" else ("unreachable" if code == "000" else "offline"))
+verified = code in ("200","401","403")
+msg = f"auth_endpoint:{auth_used}" if auth_used else ""
+print(json.dumps({"status":status,"code":code,"elapsed_ms":ms,"verified":verified,"message":msg}))
+HEALTH_PY
+}
+
+adapter_openclaw_info() {
+ local entry_json="$1"
+ python3 - "$entry_json" <<'INFO_PY'
+import json, sys
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ e = {}
+print(json.dumps({
+ "name": e.get("name",""),
+ "type": "openclaw",
+ "endpoint": f"http://{e.get('host','127.0.0.1')}:{e.get('port','')}",
+ "model": e.get("model","default"),
+ "role": e.get("role",""),
+ "verified": True
+}))
+INFO_PY
+}
+
+adapter_openclaw_version() {
+ local entry_json="$1"
+ python3 - "$entry_json" "${FLEET_ADAPTER_TIMEOUT:-6}" <<'VERSION_PY'
+import json, subprocess, sys
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ e = {}
+host = e.get("host","127.0.0.1")
+port = e.get("port")
+if not port:
+ print(json.dumps({"version":"","verified":False}))
+ sys.exit(0)
+timeout = sys.argv[2]
+url = f"http://{host}:{port}/version"
+try:
+ r = subprocess.run(["curl","-s","--max-time",str(timeout),url],
+ capture_output=True, text=True)
+ body = r.stdout.strip()
+ ver = ""
+ if body:
+ try:
+ data = json.loads(body)
+ ver = data.get("version") or data.get("openclaw",{}).get("version") or ""
+ except Exception:
+ stripped = body.lstrip()
+ if (not stripped.startswith("<")
+ and len(stripped) <= 64
+ and "\n" not in stripped):
+ ver = stripped
+ print(json.dumps({"version":ver,"verified":bool(ver)}))
+except Exception:
+ print(json.dumps({"version":"","verified":False}))
+VERSION_PY
+}
diff --git a/lib/adapters/process.sh b/lib/adapters/process.sh
new file mode 100644
index 0000000..f75bd5b
--- /dev/null
+++ b/lib/adapters/process.sh
@@ -0,0 +1,75 @@
+#!/bin/bash
+# fleet/lib/adapters/process.sh: OS process adapter.
+# Inferred: presence of a matching process is detected via pgrep. There is
+# no live handshake, so health is reported as inferred. Use this for daemons
+# that lack a public health endpoint.
+
+adapter_process_describe() {
+ echo "OS process detected via pgrep, no protocol probe (inferred)"
+}
+
+adapter_process_verified() { echo "inferred"; }
+
+adapter_process_required() { echo "process"; }
+
+adapter_process_health() {
+ local entry_json="$1"
+ python3 - "$entry_json" "${FLEET_ADAPTER_TIMEOUT:-6}" <<'HEALTH_PY'
+import json, shutil, subprocess, sys, time
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ e = {}
+timeout = sys.argv[2]
+pat = e.get("process","")
+match_full = bool(e.get("matchFull", False))
+if not pat:
+ print(json.dumps({"status":"error","code":"","elapsed_ms":0,"verified":False,"message":"process pattern required"}))
+ sys.exit(0)
+if not shutil.which("pgrep"):
+ print(json.dumps({"status":"unknown","code":"","elapsed_ms":0,"verified":False,"message":"pgrep not available"}))
+ sys.exit(0)
+start = time.time()
+args = ["pgrep","-f" if match_full else "-x", pat]
+try:
+ r = subprocess.run(args, capture_output=True, text=True, timeout=int(timeout))
+except subprocess.TimeoutExpired:
+ print(json.dumps({"status":"error","code":"","elapsed_ms":int((time.time()-start)*1000),"verified":False,"message":"pgrep timed out"}))
+ sys.exit(0)
+ms = int((time.time()-start)*1000)
+pids = [p for p in (r.stdout or "").split() if p.strip()]
+if pids:
+ status = "online"
+ code = f"pids:{len(pids)}"
+ msg = "pids " + ",".join(pids[:5])
+else:
+ status = "offline"
+ code = "pids:0"
+ msg = "no matching processes"
+print(json.dumps({"status":status,"code":code,"elapsed_ms":ms,"verified":False,"message":msg}))
+HEALTH_PY
+}
+
+adapter_process_info() {
+ local entry_json="$1"
+ python3 - "$entry_json" <<'INFO_PY'
+import json, sys
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ e = {}
+print(json.dumps({
+ "name": e.get("name",""),
+ "type": "process",
+ "endpoint": f"process:{e.get('process','')}",
+ "model": e.get("model",""),
+ "role": e.get("role",""),
+ "verified": False
+}))
+INFO_PY
+}
+
+adapter_process_version() {
+ # Most OS processes do not expose a version probe.
+ printf '{"version":"","verified":false}\n'
+}
diff --git a/lib/commands/adapters.sh b/lib/commands/adapters.sh
new file mode 100644
index 0000000..9bd7dc4
--- /dev/null
+++ b/lib/commands/adapters.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+# fleet adapters: list registered adapters with verification status and bindings.
+
+cmd_adapters() {
+ fleet_adapter_load_all
+ out_header "Fleet Adapters"
+
+ local types=()
+ while IFS= read -r t; do types+=("$t"); done < <(fleet_adapter_types)
+
+ if [ "${#types[@]}" -eq 0 ]; then
+ out_fail "No adapters registered"
+ echo " Reinstall fleet or set FLEET_ADAPTERS_DIR to a valid directory."
+ return 1
+ fi
+
+ printf " ${CLR_DIM}%-10s %-10s %-8s %s${CLR_RESET}\n" "TYPE" "MODE" "ORIGIN" "DESCRIPTION"
+ printf " ${CLR_DIM}%s${CLR_RESET}\n" "$(printf '%.0sβ' {1..62})"
+
+ local t origin desc verified label color
+ local verified_count=0 inferred_count=0
+ for t in "${types[@]}"; do
+ origin="${FLEET_ADAPTER_ORIGIN[$t]:-builtin}"
+ desc="$(adapter_"${t}"_describe 2>/dev/null || echo "no description")"
+ verified="$(adapter_"${t}"_verified 2>/dev/null || echo "unknown")"
+ if [ "$verified" = "verified" ]; then
+ color="${CLR_GREEN}"; label="verified "; verified_count=$((verified_count+1))
+ elif [ "$verified" = "inferred" ]; then
+ color="${CLR_YELLOW}"; label="inferred "; inferred_count=$((inferred_count+1))
+ else
+ color="${CLR_DIM}"; label="unknown "
+ fi
+ printf " ${CLR_BOLD}%-10s${CLR_RESET} ${color}%-10s${CLR_RESET} ${CLR_DIM}%-8s${CLR_RESET} %s\n" \
+ "$t" "$label" "$origin" "$desc"
+ done
+
+ echo ""
+ out_dim "$(printf '%d adapter%s registered %d verified %d inferred' \
+ "${#types[@]}" "$([ "${#types[@]}" -ne 1 ] && echo s)" \
+ "$verified_count" "$inferred_count")"
+
+ if fleet_has_config; then
+ echo ""
+ out_section "Bindings"
+ printf " ${CLR_DIM}%-8s %-20s %s${CLR_RESET}\n" "KIND" "NAME" "ADAPTER"
+ printf " ${CLR_DIM}%s${CLR_RESET}\n" "$(printf '%.0sβ' {1..44})"
+ local has_any=false
+ local kind line entry name a_type
+ local agent_count=0 runtime_count=0
+ while IFS= read -r line; do
+ kind="${line%%$'\t'*}"
+ entry="${line#*$'\t'}"
+ name="$(python3 -c "import json,sys; print(json.loads(sys.argv[1]).get('name',''))" "$entry" 2>/dev/null)"
+ a_type="$(fleet_adapter_resolve "$entry")"
+ if fleet_adapter_exists "$a_type"; then
+ printf " ${CLR_DIM}%-8s${CLR_RESET} %-20s ${CLR_CYAN}%s${CLR_RESET}\n" "$kind" "$name" "$a_type"
+ else
+ printf " ${CLR_DIM}%-8s${CLR_RESET} %-20s ${CLR_RED}%s (missing adapter)${CLR_RESET}\n" "$kind" "$name" "$a_type"
+ fi
+ [ "$kind" = "agent" ] && agent_count=$((agent_count+1))
+ [ "$kind" = "runtime" ] && runtime_count=$((runtime_count+1))
+ has_any=true
+ done < <(fleet_adapter_iter_entries)
+ if $has_any; then
+ echo ""
+ out_dim "$(printf '%d agent%s %d runtime%s' \
+ "$agent_count" "$([ "$agent_count" -ne 1 ] && echo s)" \
+ "$runtime_count" "$([ "$runtime_count" -ne 1 ] && echo s)")"
+ else
+ out_dim "No agents or runtimes configured."
+ fi
+ fi
+
+ echo ""
+ out_section "Legend"
+ out_dim "verified adapter probed the runtime and got a real response"
+ out_dim "inferred adapter detected presence without a protocol handshake"
+ out_dim "user adapters: drop a .sh into ${FLEET_ADAPTERS_DIR_USER}"
+ echo ""
+}
diff --git a/lib/commands/agents.sh b/lib/commands/agents.sh
index 0e92c44..77e4c4c 100644
--- a/lib/commands/agents.sh
+++ b/lib/commands/agents.sh
@@ -9,13 +9,19 @@ cmd_agents() {
return 1
fi
+ CLR_GREEN="$CLR_GREEN" CLR_RED="$CLR_RED" CLR_YELLOW="$CLR_YELLOW" \
+ CLR_DIM="$CLR_DIM" CLR_RESET="$CLR_RESET" \
python3 - "$FLEET_CONFIG_PATH" <<'AGENTS_PY'
-import json, subprocess, sys, time
+import json, os, subprocess, sys, time
with open(sys.argv[1]) as f:
config = json.load(f)
-G = "\033[32m"; R = "\033[31m"; Y = "\033[33m"; D = "\033[2m"; N = "\033[0m"
+G = os.environ.get("CLR_GREEN", "")
+R = os.environ.get("CLR_RED", "")
+Y = os.environ.get("CLR_YELLOW", "")
+D = os.environ.get("CLR_DIM", "")
+N = os.environ.get("CLR_RESET", "")
# Main gateway
gw = config.get("gateway", {})
@@ -97,4 +103,19 @@ for agent in agents:
print(f" {icon} {aname:16} {arole:16} {amodel:30} :{aport:<6} {status} {D}{ms}ms{N}")
AGENTS_PY
+
+ # ββ v4: cross runtime entries (adapter layer) βββββββββββββββββββββββββββ
+ fleet_adapter_load_all
+ local _runtime_entries=()
+ local _line _kind
+ while IFS= read -r _line; do
+ _kind="${_line%%$'\t'*}"
+ [ "$_kind" = "runtime" ] || continue
+ _runtime_entries+=("${_line#*$'\t'}")
+ done < <(fleet_adapter_iter_entries)
+
+ if [ "${#_runtime_entries[@]}" -gt 0 ]; then
+ out_section "Runtimes (v4)"
+ fleet_adapter_probe_parallel "${_runtime_entries[@]}"
+ fi
}
diff --git a/lib/commands/audit.sh b/lib/commands/audit.sh
index 0ea5445..917d4ba 100644
--- a/lib/commands/audit.sh
+++ b/lib/commands/audit.sh
@@ -64,7 +64,7 @@ import json
with open('$FLEET_CONFIG_PATH') as f:
c = json.load(f)
placeholders = [a['name'] for a in c.get('agents', [])
- if a.get('token', '') in ('your-token-here', 'your-agent-token', 'changeme', 'TODO')]
+ if str(a.get('token', '')).lower() in ('your-token-here', 'your-agent-token', 'changeme', 'todo')]
print(len(placeholders))
for n in placeholders: print(f' {n}')
" 2>/dev/null)
@@ -218,7 +218,24 @@ print(int((total - avail) / total * 100))
_inc_checks
local latest_backup
- latest_backup=$(ls -dt "$HOME/.fleet/backups"/*/ 2>/dev/null | head -1 || true)
+ latest_backup=$(python3 - "$HOME/.fleet/backups" <<'PY_LATEST_BACKUP'
+import os, sys
+root = sys.argv[1]
+best = ""
+best_mtime = -1
+try:
+ for name in os.listdir(root):
+ path = os.path.join(root, name)
+ if os.path.isdir(path):
+ mtime = os.path.getmtime(path)
+ if mtime > best_mtime:
+ best = path
+ best_mtime = mtime
+except Exception:
+ pass
+print(best)
+PY_LATEST_BACKUP
+)
if [ -n "$latest_backup" ]; then
local backup_age_days
backup_age_days=$(( ($(date +%s) - $(stat -c %Y "$latest_backup" 2>/dev/null || stat -f %m "$latest_backup" 2>/dev/null || echo "0")) / 86400 ))
diff --git a/lib/commands/backup.sh b/lib/commands/backup.sh
index 7df1b64..5b810d3 100644
--- a/lib/commands/backup.sh
+++ b/lib/commands/backup.sh
@@ -51,7 +51,24 @@ cmd_backup() {
cmd_restore() {
local latest
- latest=$(ls -dt "$HOME/.fleet/backups"/*/ 2>/dev/null | head -1)
+ latest=$(python3 - "$HOME/.fleet/backups" <<'PY_LATEST_BACKUP'
+import os, sys
+root = sys.argv[1]
+best = ""
+best_mtime = -1
+try:
+ for name in os.listdir(root):
+ path = os.path.join(root, name)
+ if os.path.isdir(path):
+ mtime = os.path.getmtime(path)
+ if mtime > best_mtime:
+ best = path
+ best_mtime = mtime
+except Exception:
+ pass
+print(best)
+PY_LATEST_BACKUP
+)
if [ -z "$latest" ]; then
out_header "Restore"
diff --git a/lib/commands/health.sh b/lib/commands/health.sh
index a80b91d..d38b420 100644
--- a/lib/commands/health.sh
+++ b/lib/commands/health.sh
@@ -62,6 +62,26 @@ PY
fi
fi
+ # ββ v4: runtimes via adapter layer ββββββββββββββββββββββββββββββββββββββ
+ if fleet_has_config; then
+ local rt_count
+ rt_count=$(_json_array_len "$FLEET_CONFIG_PATH" "runtimes")
+ if [ "$rt_count" -gt 0 ]; then
+ out_section "Runtimes"
+ fleet_adapter_load_all
+ local _h_entries=()
+ local hline hkind
+ while IFS= read -r hline; do
+ hkind="${hline%%$'\t'*}"
+ [ "$hkind" = "runtime" ] || continue
+ _h_entries+=("${hline#*$'\t'}")
+ done < <(fleet_adapter_iter_entries)
+ if [ "${#_h_entries[@]}" -gt 0 ]; then
+ fleet_adapter_probe_parallel "${_h_entries[@]}"
+ fi
+ fi
+ fi
+
# Systemd services (if any configured)
if fleet_has_config; then
local svc_count
diff --git a/lib/commands/init.sh b/lib/commands/init.sh
index d3568c7..429e17b 100644
--- a/lib/commands/init.sh
+++ b/lib/commands/init.sh
@@ -162,9 +162,11 @@ _ensure_path() {
for rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do
if [ -f "$rc" ]; then
if ! grep -q '\.local/bin' "$rc" 2>/dev/null; then
- echo '' >> "$rc"
- echo '# Added by fleet: https://github.com/oguzhnatly/fleet' >> "$rc"
- echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$rc"
+ {
+ printf '\n'
+ printf '%s\n' '# Added by fleet: https://github.com/oguzhnatly/fleet'
+ printf '%s\n' "export PATH=\"\$HOME/.local/bin:\$PATH\""
+ } >> "$rc"
out_ok "Added $bin_dir to PATH in $(basename "$rc")"
added=true
else
@@ -180,6 +182,6 @@ _ensure_path() {
out_info "PATH updated for current session"
else
out_warn "Could not find shell rc file. Add manually:"
- echo ' export PATH="$HOME/.local/bin:$PATH"'
+ printf '%s\n' " export PATH=\"\$HOME/.local/bin:\$PATH\""
fi
}
diff --git a/lib/commands/parallel.sh b/lib/commands/parallel.sh
index 1669b8b..38de885 100644
--- a/lib/commands/parallel.sh
+++ b/lib/commands/parallel.sh
@@ -273,6 +273,9 @@ with open(config_path) as f:
config = json.load(f)
agent_map = {a["name"]: a for a in config.get("agents", [])}
+policy = config.get("constitution") or config.get("policy") or {}
+if not isinstance(policy, dict):
+ policy = {}
G = "\033[32m"; R = "\033[31m"; Y = "\033[33m"; D = "\033[2m"; C = "\033[36m"; N = "\033[0m"; BOLD = "\033[1m"
@@ -282,6 +285,65 @@ lock = threading.Lock()
def now_ts():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+def policy_guard(agent, task_type, action="parallel"):
+ required = bool(policy.get("required", False) or policy.get("strict", False))
+ if not required:
+ return None
+ apply_to = policy.get("applyTo") or policy.get("apply_to") or policy.get("commands") or ["task", "parallel", "steer"]
+ if isinstance(apply_to, str):
+ apply_to = [part.strip() for part in apply_to.split(",")]
+ apply_to = [str(part).strip().lower() for part in apply_to if str(part).strip()]
+ if apply_to and action.lower() not in apply_to and "all" not in apply_to:
+ return None
+ only_agents = policy.get("agents") or []
+ if only_agents and agent not in only_agents:
+ return f"constitution required but agent '{agent}' is outside constitution scope"
+ rules = policy.get("rules") or []
+ if isinstance(rules, str):
+ rules = [rules]
+ rules = [str(r).strip() for r in rules if str(r).strip()]
+ prefix = str(policy.get("prefix") or policy.get("prompt") or "").strip()
+ if not policy.get("enabled", False):
+ return "constitution required but disabled"
+ if not rules and not prefix:
+ return "constitution required but has no rules"
+ return None
+
+def apply_policy(prompt, agent, task_type, action="parallel"):
+ if not policy.get("enabled", False):
+ return prompt
+ only_agents = policy.get("agents") or []
+ if only_agents and agent not in only_agents:
+ return prompt
+ apply_to = policy.get("applyTo") or policy.get("apply_to") or policy.get("commands") or ["task", "parallel", "steer"]
+ if isinstance(apply_to, str):
+ apply_to = [part.strip() for part in apply_to.split(",")]
+ apply_to = [str(part).strip().lower() for part in apply_to if str(part).strip()]
+ if apply_to and action.lower() not in apply_to and "all" not in apply_to:
+ return prompt
+ rules = policy.get("rules") or []
+ if isinstance(rules, str):
+ rules = [rules]
+ rules = [str(r).strip() for r in rules if str(r).strip()]
+ prefix = str(policy.get("prefix") or policy.get("prompt") or "Follow the operator constitution before doing this task.").strip()
+ title = str(policy.get("title") or "Operator Constitution").strip()
+ mode = str(policy.get("mode") or "prepend").strip().lower()
+ if not rules and not prefix:
+ return prompt
+ lines = [title]
+ if prefix:
+ lines.append(prefix)
+ lines.append(f"Agent: {agent}")
+ lines.append(f"Task type: {task_type}")
+ if rules:
+ lines.append("Rules:")
+ for i, rule in enumerate(rules, 1):
+ lines.append(f"{i}. {rule}")
+ block = "\n".join(lines).strip()
+ if mode == "append":
+ return f"{prompt}\n\n{block}"
+ return f"{block}\n\nTask:\n{prompt}"
+
def log_entry(task_id, agent, task_type, prompt, dispatched_at):
entry = {
"task_id": task_id, "agent": agent, "task_type": task_type,
@@ -305,14 +367,22 @@ def run_subtask(st):
results[agent_name] = {"ok": False, "error": f"agent '{agent_name}' not configured"}
return
+ guard_error = policy_guard(agent_name, task_type, "parallel")
+ if guard_error:
+ with lock:
+ results[agent_name] = {"ok": False, "error": guard_error}
+ return
+
task_id = str(uuid.uuid4())[:8]
session_key = f"fleet-{agent_name}"
dispatched_at = now_ts()
log_entry(task_id, agent_name, task_type, prompt, dispatched_at)
+ dispatch_prompt = apply_policy(prompt, agent_name, task_type)
+
payload = json.dumps({
"model": "openclaw",
- "messages": [{"role": "user", "content": prompt}],
+ "messages": [{"role": "user", "content": dispatch_prompt}],
})
with lock:
diff --git a/lib/commands/policy.sh b/lib/commands/policy.sh
new file mode 100644
index 0000000..bea951b
--- /dev/null
+++ b/lib/commands/policy.sh
@@ -0,0 +1,224 @@
+#!/bin/bash
+# fleet policy: Manage optional operator constitution injection.
+
+_policy_help() {
+ cat <<'HELP'
+
+ Usage:
+ fleet policy
+ fleet policy show
+ fleet policy enable
+ fleet policy disable
+ fleet policy require
+ fleet policy optional
+ fleet policy add ""
+ fleet policy rm
+ fleet policy clear
+ fleet policy title ""
+ fleet policy scope task,parallel,steer
+ fleet policy preview "" [--type code|review|research|deploy|qa] [--action task|parallel|steer]
+
+ Config key:
+ constitution.enabled = true
+ constitution.rules = ["Rule text"]
+
+HELP
+}
+
+_policy_require_config() {
+ if ! fleet_has_config; then
+ out_fail "No config at $FLEET_CONFIG_PATH"
+ echo " Run: fleet init"
+ return 1
+ fi
+}
+
+_policy_update() {
+ local action="$1" value="${2:-}"
+ _policy_require_config || return 1
+ python3 - "$FLEET_CONFIG_PATH" "$action" "$value" <<'POLICY_UPDATE_PY'
+import json, os, sys
+path, action, value = sys.argv[1:4]
+with open(path) as f:
+ cfg = json.load(f)
+policy = cfg.get("constitution")
+if not isinstance(policy, dict):
+ policy = {}
+ cfg["constitution"] = policy
+policy.setdefault("enabled", False)
+policy.setdefault("title", "Operator Constitution")
+policy.setdefault("mode", "prepend")
+policy.setdefault("required", False)
+policy.setdefault("applyTo", ["task", "parallel", "steer"])
+policy.setdefault("rules", [])
+if isinstance(policy.get("rules"), str):
+ policy["rules"] = [policy["rules"]]
+elif not isinstance(policy.get("rules"), list):
+ policy["rules"] = []
+if not isinstance(policy.get("applyTo"), list):
+ policy["applyTo"] = ["task", "parallel", "steer"]
+message = "updated"
+if action == "enable":
+ policy["enabled"] = True
+ message = "enabled"
+elif action == "disable":
+ policy["enabled"] = False
+ message = "disabled"
+elif action == "require":
+ policy["required"] = True
+ policy["enabled"] = True
+ message = "required"
+elif action == "optional":
+ policy["required"] = False
+ message = "optional"
+elif action == "add":
+ rule = value.strip()
+ if not rule:
+ print("empty_rule")
+ sys.exit(2)
+ policy["rules"].append(rule)
+ policy["enabled"] = True
+ message = f"added:{len(policy['rules'])}"
+elif action == "rm":
+ try:
+ idx = int(value)
+ except Exception:
+ print("bad_index")
+ sys.exit(2)
+ if idx < 1 or idx > len(policy["rules"]):
+ print("bad_index")
+ sys.exit(2)
+ policy["rules"].pop(idx - 1)
+ message = "removed"
+elif action == "clear":
+ policy["rules"] = []
+ message = "cleared"
+elif action == "title":
+ title = value.strip()
+ if not title:
+ print("empty_title")
+ sys.exit(2)
+ policy["title"] = title
+ message = "title"
+elif action == "scope":
+ allowed = {"task", "parallel", "steer", "all"}
+ scope = [part.strip().lower() for part in value.split(",") if part.strip()]
+ if not scope or any(part not in allowed for part in scope):
+ print("bad_scope")
+ sys.exit(2)
+ policy["applyTo"] = scope
+ message = "scope"
+else:
+ print("unknown_action")
+ sys.exit(2)
+tmp = path + ".tmp"
+with open(tmp, "w") as f:
+ json.dump(cfg, f, indent=2)
+ f.write("\n")
+os.replace(tmp, path)
+try:
+ os.chmod(path, 0o600)
+except Exception:
+ pass
+print(message)
+POLICY_UPDATE_PY
+}
+
+_policy_show() {
+ _policy_require_config || return 1
+ out_header "Operator Constitution"
+ python3 - "$(fleet_policy_summary_json)" <<'POLICY_PY'
+import json, sys
+try:
+ data = json.loads(sys.argv[1])
+except Exception:
+ data = {"enabled": False, "rules": []}
+print(f" enabled {str(data.get('enabled', False)).lower()}")
+print(f" title {data.get('title', 'Operator Constitution')}")
+print(f" mode {data.get('mode', 'prepend')}")
+print(f" required {str(data.get('required', False)).lower()}")
+agents = data.get('agents') or []
+print(f" agents {', '.join(agents) if agents else 'all'}")
+apply_to = data.get('applyTo') or ['task', 'parallel', 'steer']
+if isinstance(apply_to, str):
+ apply_to = [part.strip() for part in apply_to.split(',') if part.strip()]
+print(f" applies {', '.join(apply_to) if apply_to else 'none'}")
+print("")
+rules = data.get('rules') or []
+if rules:
+ print(" rules")
+ for i, rule in enumerate(rules, 1):
+ print(f" {i}. {rule}")
+else:
+ print(" no rules configured")
+POLICY_PY
+}
+
+_policy_preview() {
+ local agent="" prompt="" task_type="code" action="task"
+ if [[ $# -lt 2 ]]; then
+ _policy_help
+ return 1
+ fi
+ agent="$1"; shift
+ prompt="$1"; shift
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --type|-t) task_type="${2:-code}"; shift 2 ;;
+ --action|-a) action="${2:-task}"; shift 2 ;;
+ *) shift ;;
+ esac
+ done
+ out_header "Policy Preview"
+ fleet_policy_apply "$prompt" "$agent" "$task_type" "$action"
+}
+
+cmd_policy() {
+ local result
+ case "${1:-show}" in
+ show) _policy_show ;;
+ enable)
+ result="$(_policy_update enable)" || return 1
+ [ "$result" = "enabled" ] && out_ok "Operator constitution enabled"
+ ;;
+ disable)
+ result="$(_policy_update disable)" || return 1
+ [ "$result" = "disabled" ] && out_ok "Operator constitution disabled"
+ ;;
+ require)
+ result="$(_policy_update require)" || return 1
+ [ "$result" = "required" ] && out_ok "Operator constitution required"
+ ;;
+ optional)
+ result="$(_policy_update optional)" || return 1
+ [ "$result" = "optional" ] && out_ok "Operator constitution optional"
+ ;;
+ add)
+ if [[ $# -lt 2 ]]; then echo " Usage: fleet policy add \"\""; return 1; fi
+ result="$(_policy_update add "$2")" || { out_fail "Could not add rule"; return 1; }
+ out_ok "Added constitution rule"
+ ;;
+ rm|remove)
+ if [[ $# -lt 2 ]]; then echo " Usage: fleet policy rm "; return 1; fi
+ result="$(_policy_update rm "$2")" || { out_fail "Rule index not found"; return 1; }
+ [ "$result" = "removed" ] && out_ok "Removed constitution rule"
+ ;;
+ clear)
+ result="$(_policy_update clear)" || return 1
+ [ "$result" = "cleared" ] && out_ok "Cleared constitution rules"
+ ;;
+ title)
+ if [[ $# -lt 2 ]]; then echo " Usage: fleet policy title \"\""; return 1; fi
+ result="$(_policy_update title "$2")" || { out_fail "Could not set title"; return 1; }
+ [ "$result" = "title" ] && out_ok "Updated constitution title"
+ ;;
+ scope)
+ if [[ $# -lt 2 ]]; then echo " Usage: fleet policy scope task,parallel,steer"; return 1; fi
+ result="$(_policy_update scope "$2")" || { out_fail "Scope must use task, parallel, steer, or all"; return 1; }
+ [ "$result" = "scope" ] && out_ok "Updated constitution scope"
+ ;;
+ preview) _policy_preview "${@:2}" ;;
+ help|--help|-h) _policy_help ;;
+ *) _policy_help; return 1 ;;
+ esac
+}
diff --git a/lib/commands/runtime.sh b/lib/commands/runtime.sh
new file mode 100644
index 0000000..5356441
--- /dev/null
+++ b/lib/commands/runtime.sh
@@ -0,0 +1,404 @@
+#!/bin/bash
+# fleet runtime: register, probe, list, and remove cross runtime entries.
+
+cmd_runtime() {
+ fleet_adapter_load_all
+ local sub="${1:-}"
+ case "$sub" in
+ add) shift; _runtime_add "$@" ;;
+ test|probe) shift; _runtime_test "$@" ;;
+ list|ls) shift; _runtime_list "$@" ;;
+ rm|remove|delete) shift; _runtime_remove "$@" ;;
+ ""|help|-h|--help) _runtime_help ;;
+ *)
+ echo " Unknown runtime subcommand: $sub"
+ echo " Run 'fleet runtime help' for usage."
+ return 1
+ ;;
+ esac
+}
+
+_runtime_help() {
+ cat <<'HELP'
+
+ fleet runtime cross runtime registry (v4)
+
+ USAGE
+ fleet runtime add [options] Register a new runtime
+ fleet runtime test One off health probe
+ fleet runtime list List runtimes with live status
+ fleet runtime rm Remove a runtime
+
+ TYPES
+ openclaw OpenClaw gateway probe (port required, verified)
+ http Generic HTTP probe (url required, verified)
+ docker Docker container state (container required, verified)
+ process OS process via pgrep (process required, inferred)
+
+ OPTIONS
+ --port=N OpenClaw port
+ --host=H OpenClaw host (default 127.0.0.1)
+ --token=T Bearer token for HTTP or OpenClaw auth
+ --url=URL HTTP probe URL
+ --version-url=URL Optional separate version endpoint
+ --expected-status=N Expected HTTP status (default 200)
+ --method=M HTTP method (default GET)
+ --container=NAME Docker container name or id
+ --process=PAT Process pattern for pgrep
+ --match-full Match full command line (process adapter)
+ --role=R Display label
+ --model=M Display label
+ --header K=V Extra HTTP header (repeatable)
+
+ EXAMPLES
+ fleet runtime add billing-api http --url=https://api.example.com/health
+ fleet runtime add postgres docker --container=postgres
+ fleet runtime add tailscale process --process=tailscaled
+ fleet runtime test billing-api
+
+HELP
+}
+
+# Parse argv into a single JSON entry and echo the JSON.
+_runtime_parse_args() {
+ local name="$1" type="$2"; shift 2
+ python3 - "$name" "$type" "$@" <<'PARSE_PY'
+import json, sys
+name, atype = sys.argv[1], sys.argv[2]
+entry = {"name": name, "adapter": atype}
+i = 3
+headers = {}
+def parse_bool(value):
+ if value is True:
+ return True
+ if isinstance(value, str):
+ lowered = value.strip().lower()
+ if lowered in ("false", "0", "no", "off", "n"):
+ return False
+ if lowered in ("true", "1", "yes", "on", "y"):
+ return True
+ return bool(value)
+while i < len(sys.argv):
+ arg = sys.argv[i]
+ key = val = None
+ if arg.startswith("--"):
+ if "=" in arg:
+ key, val = arg[2:].split("=", 1)
+ else:
+ key = arg[2:]
+ if i + 1 < len(sys.argv) and not sys.argv[i+1].startswith("--"):
+ val = sys.argv[i+1]
+ i += 1
+ else:
+ val = True
+ i += 1
+ else:
+ i += 1
+ continue
+ norm = key.replace("-", "_")
+ if norm == "port":
+ try: entry["port"] = int(val)
+ except Exception: entry["port"] = val
+ elif norm == "host": entry["host"] = val
+ elif norm == "token": entry["token"] = val
+ elif norm == "url": entry["url"] = val
+ elif norm == "version_url": entry["versionUrl"] = val
+ elif norm == "expected_status":
+ try: entry["expectedStatus"] = int(val)
+ except Exception: entry["expectedStatus"] = val
+ elif norm == "method": entry["method"] = val
+ elif norm == "container": entry["container"] = val
+ elif norm == "process": entry["process"] = val
+ elif norm == "match_full": entry["matchFull"] = parse_bool(val)
+ elif norm == "role": entry["role"] = val
+ elif norm == "model": entry["model"] = val
+ elif norm == "header" and isinstance(val, str) and "=" in val:
+ k, v = val.split("=", 1)
+ headers[k] = v
+ else:
+ entry[norm] = val
+if headers:
+ entry["headers"] = headers
+print(json.dumps(entry))
+PARSE_PY
+}
+
+_runtime_add() {
+ if [ "$#" -lt 2 ]; then
+ echo " Usage: fleet runtime add [options]"
+ echo " Run 'fleet runtime help' for full options."
+ return 1
+ fi
+ local name="$1" type="$2"
+ if ! fleet_adapter_exists "$type"; then
+ out_fail "Unknown adapter type: $type"
+ printf " Available types: %s\n" "$(fleet_adapter_types | tr '\n' ' ')"
+ return 1
+ fi
+ fleet_validate_config 2>/dev/null || {
+ out_fail "No config at $FLEET_CONFIG_PATH"
+ echo " Run: fleet init"
+ return 1
+ }
+ local entry_json
+ entry_json="$(_runtime_parse_args "$@")"
+
+ local validation
+ validation="$(fleet_adapter_validate "$entry_json")"
+ if [ "$validation" != "ok" ]; then
+ out_fail "Missing required fields for $type adapter: $validation"
+ echo " Run 'fleet runtime help' for the option list."
+ return 1
+ fi
+
+ local action
+ action="$(python3 - "$FLEET_CONFIG_PATH" "$entry_json" <<'ADD_PY'
+import json, os, sys
+config_path = sys.argv[1]
+entry = json.loads(sys.argv[2])
+with open(config_path) as f:
+ cfg = json.load(f)
+cfg.setdefault("runtimes", [])
+if any(a.get("name") == entry["name"] for a in cfg.get("agents", [])):
+ print("DUPLICATE_AGENT")
+ sys.exit(2)
+existing = next((i for i,r in enumerate(cfg["runtimes"]) if r.get("name") == entry["name"]), -1)
+if existing >= 0:
+ cfg["runtimes"][existing] = entry
+ action = "updated"
+else:
+ cfg["runtimes"].append(entry)
+ action = "added"
+tmp = config_path + ".tmp"
+with open(tmp, "w") as f:
+ json.dump(cfg, f, indent=2)
+os.replace(tmp, config_path)
+try: os.chmod(config_path, 0o600)
+except Exception: pass
+print(action)
+ADD_PY
+)"
+ local rc=$?
+ if [ $rc -eq 2 ] || [ "$action" = "DUPLICATE_AGENT" ]; then
+ out_fail "Name '$name' is already used by an agent. Choose another name."
+ return 1
+ fi
+ if [ "$action" = "updated" ]; then
+ out_ok "Updated runtime '$name' (adapter: $type)"
+ else
+ out_ok "Registered runtime '$name' (adapter: $type)"
+ fi
+ out_dim "Probe now: fleet runtime test $name"
+}
+
+# Animated single-target probe with spinner and sectioned reveal.
+_runtime_spinner() {
+ local pid="$1" label="$2"
+ local frames=("β " "β " "β Ή" "β Έ" "β Ό" "β ΄" "β ¦" "β §" "β " "β ")
+ local i=0
+ [ -t 1 ] || { wait "$pid" 2>/dev/null; return; }
+ while kill -0 "$pid" 2>/dev/null; do
+ printf "\r ${CLR_CYAN}%s${CLR_RESET} %s${CLR_DIM}...${CLR_RESET}" \
+ "${frames[$((i % 10))]}" "$label"
+ i=$((i+1))
+ sleep 0.07
+ done
+ printf "\r\033[K"
+ wait "$pid" 2>/dev/null
+}
+
+_runtime_test() {
+ if [ "$#" -lt 1 ]; then
+ echo " Usage: fleet runtime test "
+ return 1
+ fi
+ local name="$1"
+ local entry_json
+ entry_json="$(fleet_runtime_get "$name")" || {
+ out_fail "No runtime or agent named '$name'"
+ return 1
+ }
+ local atype
+ atype="$(fleet_adapter_resolve "$entry_json")"
+ local atype_verified
+ atype_verified="$(adapter_"${atype}"_verified 2>/dev/null || echo "unknown")"
+ local atype_desc
+ atype_desc="$(adapter_"${atype}"_describe 2>/dev/null || echo "")"
+
+ echo ""
+ printf " ${CLR_BOLD}Probe${CLR_RESET} %s\n" "$name"
+ printf " %s\n" "$(printf '%.0sβ' {1..56})"
+ printf " %-14s %s\n" "adapter" "${CLR_CYAN}${atype}${CLR_RESET}"
+ if [ "$atype_verified" = "verified" ]; then
+ printf " %-14s %s\n" "mode" "${CLR_GREEN}verified${CLR_RESET}"
+ else
+ printf " %-14s %s\n" "mode" "${CLR_YELLOW}inferred${CLR_RESET}"
+ fi
+ printf " %-14s %s\n" "description" "${CLR_DIM}${atype_desc}${CLR_RESET}"
+ echo ""
+
+ local tmp_health tmp_info tmp_ver
+ tmp_health="$(mktemp -t fleet_health.XXXXXX)"
+ tmp_info="$(mktemp -t fleet_info.XXXXXX)"
+ tmp_ver="$(mktemp -t fleet_ver.XXXXXX)"
+
+ fleet_adapter_info "$entry_json" > "$tmp_info" &
+ fleet_adapter_version "$entry_json" > "$tmp_ver" &
+ local pid_i=$! pid_v=$!
+
+ ( fleet_adapter_health "$entry_json" > "$tmp_health" ) &
+ local pid_h=$!
+ _runtime_spinner "$pid_h" "running health probe"
+ wait "$pid_i" "$pid_v" 2>/dev/null
+
+ # Health section
+ printf " %sHealth%s\n" "$CLR_BOLD" "$CLR_RESET"
+ CLR_GREEN="$CLR_GREEN" CLR_RED="$CLR_RED" CLR_YELLOW="$CLR_YELLOW" \
+ CLR_DIM="$CLR_DIM" CLR_BOLD="$CLR_BOLD" CLR_RESET="$CLR_RESET" \
+ python3 - "$(cat "$tmp_health")" <<'HEALTH_PY'
+import json, os, sys
+try:
+ d = json.loads(sys.argv[1])
+except Exception:
+ print(" could not parse adapter response")
+ sys.exit(0)
+G=os.environ.get("CLR_GREEN",""); R=os.environ.get("CLR_RED",""); Y=os.environ.get("CLR_YELLOW",""); D=os.environ.get("CLR_DIM",""); B=os.environ.get("CLR_BOLD",""); N=os.environ.get("CLR_RESET","")
+status = d.get("status","?"); code = d.get("code",""); ms = d.get("elapsed_ms",0)
+verified = d.get("verified", False); msg = d.get("message","")
+if status == "online":
+ icon = G+"β¬’"+N; badge = G+"β online"+N
+elif status in ("starting","degraded","auth_failed"):
+ icon = Y+"β"+N; badge = Y+f"β {status}"+N
+else:
+ icon = R+"⬑"+N; badge = R+f"β {status}"+N
+ver_tag = (D+"verified"+N) if verified else (Y+"inferred"+N)
+print(f" {icon} {badge} {D}code={code} {ms}ms [{ver_tag}{D}]{N}")
+if msg: print(f" {D}note: {msg}{N}")
+HEALTH_PY
+
+ echo ""
+
+ # Info section
+ printf " %sInfo%s\n" "$CLR_BOLD" "$CLR_RESET"
+ CLR_DIM="$CLR_DIM" CLR_CYAN="$CLR_CYAN" CLR_RESET="$CLR_RESET" \
+ python3 - "$(cat "$tmp_info")" <<'INFO_PY'
+import json, os, sys
+try: d = json.loads(sys.argv[1])
+except Exception: d = {}
+D=os.environ.get("CLR_DIM",""); C=os.environ.get("CLR_CYAN",""); N=os.environ.get("CLR_RESET","")
+for k in ("type","endpoint","role","model"):
+ v = d.get(k,"")
+ if v: print(f" {D}{k:<12}{N} {C}{v}{N}")
+INFO_PY
+
+ echo ""
+
+ # Version section
+ printf " %sVersion%s\n" "$CLR_BOLD" "$CLR_RESET"
+ CLR_GREEN="$CLR_GREEN" CLR_YELLOW="$CLR_YELLOW" CLR_DIM="$CLR_DIM" CLR_RESET="$CLR_RESET" \
+ python3 - "$(cat "$tmp_ver")" <<'VER_PY'
+import json, os, sys
+try: d = json.loads(sys.argv[1])
+except Exception: d = {}
+G=os.environ.get("CLR_GREEN",""); Y=os.environ.get("CLR_YELLOW",""); D=os.environ.get("CLR_DIM",""); N=os.environ.get("CLR_RESET","")
+v = d.get("version",""); ok = d.get("verified", False)
+if v:
+ tag = (G+"verified"+N) if ok else (Y+"best effort"+N)
+ print(f" {v} {D}({N}{tag}{D}){N}")
+else:
+ print(f" {D}unknown{N}")
+VER_PY
+
+ echo ""
+
+ # Verdict footer
+ CLR_GREEN="$CLR_GREEN" CLR_RED="$CLR_RED" CLR_YELLOW="$CLR_YELLOW" \
+ CLR_DIM="$CLR_DIM" CLR_BOLD="$CLR_BOLD" CLR_RESET="$CLR_RESET" \
+ python3 - "$(cat "$tmp_health")" "$name" "$atype" <<'VERDICT_PY'
+import json, os, sys
+try:
+ h = json.loads(sys.argv[1])
+except Exception:
+ h = {}
+name, atype = sys.argv[2], sys.argv[3]
+G=os.environ.get("CLR_GREEN",""); R=os.environ.get("CLR_RED",""); Y=os.environ.get("CLR_YELLOW",""); D=os.environ.get("CLR_DIM",""); B=os.environ.get("CLR_BOLD",""); N=os.environ.get("CLR_RESET","")
+s = h.get("status","?"); ms = h.get("elapsed_ms",0)
+if s == "online":
+ icon = G+"β"+N; label = G+f"{name} is reachable"+N
+elif s in ("starting","degraded"):
+ icon = Y+"β"+N; label = Y+f"{name} is {s}"+N
+elif s == "auth_failed":
+ icon = Y+"β"+N; label = Y+f"{name}: auth failed, check token"+N
+else:
+ icon = R+"β"+N; label = R+f"{name} is {s}"+N
+print(f" {D}{'β'*56}{N}")
+print(f" {icon} {B}{label}{N} {D}via {atype} {ms}ms{N}")
+print()
+VERDICT_PY
+ rm -f "$tmp_health" "$tmp_info" "$tmp_ver"
+}
+
+_runtime_list() {
+ fleet_validate_config 2>/dev/null || {
+ out_fail "No config at $FLEET_CONFIG_PATH"
+ echo " Run: fleet init"
+ return 1
+ }
+ out_header "Runtimes"
+
+ local entries=()
+ local line kind
+ while IFS= read -r line; do
+ kind="${line%%$'\t'*}"
+ [ "$kind" = "runtime" ] || continue
+ entries+=("${line#*$'\t'}")
+ done < <(fleet_adapter_iter_entries)
+
+ if [ "${#entries[@]}" -eq 0 ]; then
+ out_dim "No runtimes registered."
+ out_dim "Add one with: fleet runtime add [options]"
+ echo ""
+ return 0
+ fi
+
+ fleet_adapter_probe_parallel "${entries[@]}"
+ echo ""
+}
+
+_runtime_remove() {
+ if [ "$#" -lt 1 ]; then
+ echo " Usage: fleet runtime rm "
+ return 1
+ fi
+ local name="$1"
+ fleet_validate_config 2>/dev/null || {
+ out_fail "No config at $FLEET_CONFIG_PATH"
+ return 1
+ }
+ local result
+ result="$(python3 - "$FLEET_CONFIG_PATH" "$name" <<'RM_PY'
+import json, os, sys
+path, name = sys.argv[1], sys.argv[2]
+with open(path) as f:
+ cfg = json.load(f)
+runtimes = cfg.get("runtimes", [])
+new = [r for r in runtimes if r.get("name") != name]
+if len(new) == len(runtimes):
+ print("not_found")
+ sys.exit(0)
+cfg["runtimes"] = new
+tmp = path + ".tmp"
+with open(tmp, "w") as f:
+ json.dump(cfg, f, indent=2)
+os.replace(tmp, path)
+try: os.chmod(path, 0o600)
+except Exception: pass
+print("removed")
+RM_PY
+)"
+ if [ "$result" = "not_found" ]; then
+ out_fail "No runtime named '$name'"
+ return 1
+ fi
+ out_ok "Removed runtime '$name'"
+}
diff --git a/lib/commands/sitrep.sh b/lib/commands/sitrep.sh
index c7d82d5..9f50aa7 100644
--- a/lib/commands/sitrep.sh
+++ b/lib/commands/sitrep.sh
@@ -4,6 +4,8 @@
cmd_sitrep() {
local hours="${1:-4}"
+ CLR_GREEN="$CLR_GREEN" CLR_RED="$CLR_RED" CLR_YELLOW="$CLR_YELLOW" \
+ CLR_BLUE="$CLR_BLUE" CLR_DIM="$CLR_DIM" CLR_BOLD="$CLR_BOLD" CLR_RESET="$CLR_RESET" \
python3 - "$FLEET_CONFIG_PATH" "$FLEET_STATE_DIR" "$hours" <<'SITREP_PY'
import json, subprocess, sys, os, time
from datetime import datetime, timezone
@@ -12,8 +14,13 @@ config_path = sys.argv[1]
state_dir = sys.argv[2]
hours = sys.argv[3]
-G = "\033[32m"; R = "\033[31m"; Y = "\033[33m"; B = "\033[34m"
-D = "\033[2m"; BOLD = "\033[1m"; N = "\033[0m"
+G = os.environ.get("CLR_GREEN", "")
+R = os.environ.get("CLR_RED", "")
+Y = os.environ.get("CLR_YELLOW", "")
+B = os.environ.get("CLR_BLUE", "")
+D = os.environ.get("CLR_DIM", "")
+BOLD = os.environ.get("CLR_BOLD", "")
+N = os.environ.get("CLR_RESET", "")
# Load config
config = {}
@@ -107,6 +114,58 @@ for agent in config.get("agents", []):
except Exception:
agents[agent["name"]] = "error"
+# v4: runtimes via adapter dispatcher (shell out to fleet adapter health)
+runtimes = {}
+runtimes_meta = {}
+fleet_root = os.environ.get("FLEET_ROOT", "")
+rt_list = config.get("runtimes", [])
+if rt_list and fleet_root and os.path.isdir(fleet_root):
+ # Probe runtimes in parallel so a slow target does not stall sitrep.
+ from concurrent.futures import ThreadPoolExecutor, as_completed
+
+ def _probe_runtime(rt):
+ rname = rt.get("name", "?")
+ atype = rt.get("adapter") or rt.get("type") or "openclaw"
+ mode = "unknown"
+ try:
+ r = subprocess.run(
+ ["bash", "-c",
+ f'source "{fleet_root}/lib/core/adapters.sh" 2>/dev/null; '
+ f'fleet_adapter_load_all; '
+ f'if fleet_adapter_exists "$2"; then adapter_"$2"_verified; else echo unknown; fi; '
+ f'fleet_adapter_health "$1"',
+ "_", json.dumps(rt), atype],
+ capture_output=True, text=True, timeout=10,
+ env={**os.environ, "FLEET_ROOT": fleet_root}
+ )
+ lines = [ln for ln in (r.stdout or "").splitlines() if ln.strip()]
+ if lines:
+ mode = lines[0].strip() or "unknown"
+ line = lines[-1] if lines else "{}"
+ d = json.loads(line)
+ except Exception:
+ d = {}
+ return rname, atype, mode, d
+
+ with ThreadPoolExecutor(max_workers=min(8, max(1, len(rt_list)))) as pool:
+ futures = [pool.submit(_probe_runtime, rt) for rt in rt_list]
+ for fut in as_completed(futures):
+ rname, atype, mode, d = fut.result()
+ runtimes[rname] = d.get("status", "error")
+ runtimes_meta[rname] = {
+ "adapter": atype,
+ "mode": mode,
+ "ms": d.get("elapsed_ms", 0),
+ }
+
+elif rt_list:
+ # FLEET_ROOT missing: report runtimes as unprobed but list them.
+ for rt in rt_list:
+ rname = rt.get("name", "?")
+ atype = rt.get("adapter") or rt.get("type") or "openclaw"
+ runtimes[rname] = "unprobed"
+ runtimes_meta[rname] = {"adapter": atype, "mode": "unknown", "ms": 0}
+
# Linear (optional)
linear = {}
linear_conf = config.get("linear", {})
@@ -178,10 +237,15 @@ for team, count in linear.items():
if delta != 0:
changes.append(f"{team} tickets: {'+' if delta > 0 else ''}{delta}")
+prev_rt = prev.get("runtimes", {})
+for rname, status in runtimes.items():
+ if prev_rt.get(rname) and prev_rt[rname] != status:
+ changes.append(f"runtime {rname}: {prev_rt[rname]} β {status}")
+
# Save state
current = {
"timestamp": timestamp, "ci": ci, "endpoints": endpoints,
- "agents": agents, "linear": linear,
+ "agents": agents, "runtimes": runtimes, "linear": linear,
"vps": {"mem_pct": mem_pct, "disk_pct": disk_pct}
}
with open(state_file, "w") as f:
@@ -217,6 +281,25 @@ if ci:
else:
print(f" {D}?{N} {repo} ({status})")
+# Runtimes (v4)
+if runtimes:
+ online_rt = sum(1 for s in runtimes.values() if s == "online")
+ total_rt = len(runtimes)
+ rcolor = G if online_rt == total_rt else (Y if online_rt > 0 else R)
+ print(f"\n{BOLD}Runtimes{N} {rcolor}{online_rt}/{total_rt} online{N}")
+ for rname, status in runtimes.items():
+ meta = runtimes_meta.get(rname, {})
+ atype = meta.get("adapter", "?"); mode = meta.get("mode", "unknown")
+ verify_tag = f"{D}{mode}{N}" if mode == "verified" else f"{Y}{mode}{N}"
+ if status == "online":
+ print(f" {G}β¬’{N} {rname} {D}({atype}){N} {verify_tag}")
+ elif status in ("starting","degraded","auth_failed"):
+ print(f" {Y}β {N} {rname} {status} {D}({atype}){N} {verify_tag}")
+ elif status == "unprobed":
+ print(f" {D}?{N} {rname} unprobed {D}({atype}, FLEET_ROOT not set){N}")
+ else:
+ print(f" {R}⬑{N} {rname} {status} {D}({atype}){N} {verify_tag}")
+
# Endpoints
if endpoints:
print(f"\n{BOLD}Services{N}")
diff --git a/lib/commands/steer.sh b/lib/commands/steer.sh
index c635f65..0a877c6 100644
--- a/lib/commands/steer.sh
+++ b/lib/commands/steer.sh
@@ -21,6 +21,15 @@ cmd_steer() {
return 1
fi
+ local policy_guard dispatch_message
+ policy_guard="$(fleet_policy_guard "$agent" "steer" "steer")"
+ if [ "$policy_guard" != "ok" ]; then
+ out_fail "$policy_guard"
+ echo " Run: fleet policy"
+ return 1
+ fi
+ dispatch_message="$(fleet_policy_apply "$message" "$agent" "steer" "steer")"
+
out_header "Fleet Steer"
out_kv "Agent" "$agent"
out_kv "Session" "fleet-$agent"
@@ -32,7 +41,7 @@ cmd_steer() {
fleet_log_steer "$agent"
# Send to the same session used by fleet task
- python3 -u - "$port" "$token" "$message" "$agent" <<'PY'
+ python3 -u - "$port" "$token" "$dispatch_message" "$agent" <<'PY'
import subprocess, sys, json
port, token, message, agent = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
diff --git a/lib/commands/task.sh b/lib/commands/task.sh
index d08a698..ed7eabd 100644
--- a/lib/commands/task.sh
+++ b/lib/commands/task.sh
@@ -80,6 +80,16 @@ cmd_task() {
task_type="$(_infer_task_type "$prompt")"
fi
+ # ββ Apply optional operator constitution ββββββββββββββββββββββββββββββββ
+ local policy_guard dispatch_prompt
+ policy_guard="$(fleet_policy_guard "$agent" "$task_type" "task")"
+ if [ "$policy_guard" != "ok" ]; then
+ out_fail "$policy_guard"
+ echo " Run: fleet policy"
+ return 1
+ fi
+ dispatch_prompt="$(fleet_policy_apply "$prompt" "$agent" "$task_type" "task")"
+
# ββ Log dispatch ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
local task_id
task_id="$(fleet_log_dispatch "$agent" "$task_type" "$prompt")"
@@ -95,7 +105,7 @@ cmd_task() {
if [ "$no_wait" = "true" ]; then
# Fire and forget: dispatch without waiting for response
- python3 -u - "$port" "$token" "$prompt" "$agent" <<'PY'
+ python3 -u - "$port" "$token" "$dispatch_prompt" "$agent" <<'PY'
import subprocess, sys, json
port, token, prompt, agent = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
@@ -125,7 +135,7 @@ PY
local outcome="success"
echo -e " ${CLR_DIM}ββββββββββββββββββββββββββββββββββββββββ${CLR_RESET}"
- python3 -u - "$port" "$token" "$prompt" "$agent" "$timeout_min" <<'PY'
+ python3 -u - "$port" "$token" "$dispatch_prompt" "$agent" "$timeout_min" <<'PY'
import subprocess, sys, json, signal, time
port = sys.argv[1]
diff --git a/lib/core/adapters.sh b/lib/core/adapters.sh
new file mode 100644
index 0000000..d099d6c
--- /dev/null
+++ b/lib/core/adapters.sh
@@ -0,0 +1,475 @@
+#!/usr/bin/env bash
+# fleet/lib/core/adapters.sh: Cross runtime adapter registry and dispatcher.
+#
+# Each adapter lives in lib/adapters/.sh and defines six functions:
+#
+# adapter__describe
+# Echo a one line human description.
+#
+# adapter__verified
+# Echo "verified" or "inferred". Verified means the adapter actually
+# confirms a live response. Inferred means presence is detected without
+# a real handshake (e.g. pgrep matched a process).
+#
+# adapter__required
+# Echo a space separated list of required entry fields. Empty if none.
+#
+# adapter__health
+# Read entry json from arg 1, run a health probe, print a JSON object
+# with at least: status (online|offline|unreachable|degraded|starting|
+# auth_failed|unknown|error), code, elapsed_ms, verified (true|false),
+# message. Always exit 0; status is in the JSON.
+#
+# adapter__info
+# Print a JSON object describing the entry as the adapter sees it:
+# name, type, endpoint, verified, optional model, role, extras.
+#
+# adapter__version
+# Print { "version": "...", "verified": true|false }. Adapters that
+# cannot determine a version return version "" and verified false.
+#
+# All adapter implementations must tolerate missing optional fields, must not
+# crash on bad json, and must complete within FLEET_ADAPTER_TIMEOUT seconds
+# (default 6) so a single bad runtime cannot stall the fleet.
+
+# shellcheck disable=SC2034
+FLEET_ADAPTER_TIMEOUT="${FLEET_ADAPTER_TIMEOUT:-6}"
+FLEET_ADAPTERS_DIR_BUILTIN="$FLEET_ROOT/lib/adapters"
+FLEET_ADAPTERS_DIR_USER="${FLEET_ADAPTERS_DIR:-$HOME/.fleet/adapters}"
+
+if [ -n "${BASH_VERSION:-}" ] && [ "${BASH_VERSINFO[0]:-0}" -lt 4 ] 2>/dev/null; then
+ echo "fleet adapter layer requires bash 4 or newer." >&2
+ if (return 0 2>/dev/null); then
+ return 1
+ fi
+ exit 1
+fi
+
+# Registry: associative arrays of type to source path and origin.
+declare -gA FLEET_ADAPTER_PATHS
+declare -gA FLEET_ADAPTER_ORIGIN
+
+# ββ Loader ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+fleet_adapter_load_all() {
+ FLEET_ADAPTER_PATHS=()
+ FLEET_ADAPTER_ORIGIN=()
+ local dir adapter type
+ for dir in "$FLEET_ADAPTERS_DIR_BUILTIN" "$FLEET_ADAPTERS_DIR_USER"; do
+ [ -d "$dir" ] || continue
+ local origin="builtin"
+ [ "$dir" = "$FLEET_ADAPTERS_DIR_USER" ] && origin="user"
+ for adapter in "$dir"/*.sh; do
+ [ -f "$adapter" ] || continue
+ type="$(basename "$adapter" .sh)"
+ # shellcheck source=/dev/null
+ source "$adapter"
+ FLEET_ADAPTER_PATHS["$type"]="$adapter"
+ FLEET_ADAPTER_ORIGIN["$type"]="$origin"
+ done
+ done
+}
+
+fleet_adapter_types() {
+ local t
+ for t in "${!FLEET_ADAPTER_PATHS[@]}"; do echo "$t"; done | sort
+}
+
+fleet_adapter_exists() {
+ local type="$1"
+ [ -n "${FLEET_ADAPTER_PATHS[$type]:-}" ]
+}
+
+_fleet_adapter_timeout_seconds() {
+ local timeout="${FLEET_ADAPTER_TIMEOUT:-6}"
+ case "$timeout" in
+ ''|*[!0-9]*) timeout=6 ;;
+ esac
+ [ "$timeout" -lt 1 ] && timeout=1
+ echo "$timeout"
+}
+
+_fleet_adapter_error_json() {
+ local type="$1" op="$2" message="$3"
+ python3 - "$type" "$op" "$message" <<'ADAPTER_ERROR_PY'
+import json, sys
+type_name, op, message = sys.argv[1:4]
+if op == "health":
+ payload = {"status":"error","code":"","elapsed_ms":0,"verified":False,"message":message}
+elif op == "info":
+ payload = {"type":type_name,"verified":False,"message":message}
+else:
+ payload = {"version":"","verified":False,"message":message}
+print(json.dumps(payload, separators=(",",":")))
+ADAPTER_ERROR_PY
+}
+
+_fleet_adapter_call_with_timeout() {
+ local type="$1" op="$2" entry_json="$3"
+ local fn="adapter_${type}_${op}"
+ if ! declare -F "$fn" >/dev/null; then
+ _fleet_adapter_error_json "$type" "$op" "adapter function missing: $fn"
+ return 0
+ fi
+
+ local timeout limit i tmp pid
+ timeout="$(_fleet_adapter_timeout_seconds)"
+ limit=$((timeout * 10))
+ tmp="$(mktemp -t fleet_adapter.XXXXXX)"
+ rm -f "$tmp" "$tmp.out" "$tmp.status"
+
+ (
+ set +e
+ "$fn" "$entry_json" > "$tmp.out" 2>/dev/null
+ printf '%s\n' "$?" > "$tmp.status"
+ ) &
+ pid=$!
+
+ i=0
+ while [ "$i" -lt "$limit" ]; do
+ if [ -f "$tmp.status" ]; then
+ wait "$pid" 2>/dev/null || true
+ cat "$tmp.out" 2>/dev/null || true
+ rm -f "$tmp" "$tmp.out" "$tmp.status"
+ return 0
+ fi
+ sleep 0.1
+ i=$((i + 1))
+ done
+
+ kill "$pid" 2>/dev/null || true
+ wait "$pid" 2>/dev/null || true
+ rm -f "$tmp" "$tmp.out" "$tmp.status"
+ _fleet_adapter_error_json "$type" "$op" "adapter $type $op timed out after ${timeout}s"
+}
+
+# Resolve which adapter type to use for a given entry.
+# Order: entry.adapter, entry.type, default openclaw.
+fleet_adapter_resolve() {
+ local entry_json="$1"
+ python3 - "$entry_json" <<'RESOLVE_PY'
+import json, sys
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ e = {}
+val = e.get("adapter") or e.get("type") or "openclaw"
+print(val)
+RESOLVE_PY
+}
+
+# Validate entry json against the resolved adapter's required fields.
+# Echo "ok" or a comma separated list of missing field names.
+fleet_adapter_validate() {
+ local entry_json="$1"
+ local type
+ type="$(fleet_adapter_resolve "$entry_json")"
+ if ! fleet_adapter_exists "$type"; then
+ echo "missing_adapter:$type"
+ return 1
+ fi
+ local required
+ required="$(adapter_"${type}"_required 2>/dev/null || echo "")"
+ [ -z "$required" ] && { echo "ok"; return 0; }
+ python3 - "$entry_json" "$required" <<'VALIDATE_PY'
+import json, sys
+try:
+ e = json.loads(sys.argv[1])
+except Exception:
+ e = {}
+required = sys.argv[2].split()
+missing = [f for f in required if not e.get(f)]
+print("ok" if not missing else ",".join(missing))
+VALIDATE_PY
+}
+
+# Dispatch helpers. Each falls back to a clean error JSON if the adapter is
+# missing or fails. Output is always parseable JSON on stdout.
+fleet_adapter_health() {
+ local entry_json="$1"
+ local type
+ type="$(fleet_adapter_resolve "$entry_json")"
+ if ! fleet_adapter_exists "$type"; then
+ printf '{"status":"error","code":"","elapsed_ms":0,"verified":false,"message":"unknown adapter: %s"}\n' "$type"
+ return 0
+ fi
+ local out
+ out="$(_fleet_adapter_call_with_timeout "$type" health "$entry_json" || echo "")"
+ if [ -z "$out" ]; then
+ printf '{"status":"error","code":"","elapsed_ms":0,"verified":false,"message":"adapter %s returned no output"}\n' "$type"
+ return 0
+ fi
+ echo "$out"
+}
+
+fleet_adapter_info() {
+ local entry_json="$1"
+ local type
+ type="$(fleet_adapter_resolve "$entry_json")"
+ if ! fleet_adapter_exists "$type"; then
+ printf '{"type":"%s","verified":false,"message":"unknown adapter"}\n' "$type"
+ return 0
+ fi
+ local out
+ out="$(_fleet_adapter_call_with_timeout "$type" info "$entry_json" || echo "")"
+ if [ -n "$out" ]; then
+ echo "$out"
+ else
+ printf '{"type":"%s","verified":false,"message":"info failed"}\n' "$type"
+ fi
+}
+
+fleet_adapter_version() {
+ local entry_json="$1"
+ local type
+ type="$(fleet_adapter_resolve "$entry_json")"
+ if ! fleet_adapter_exists "$type"; then
+ printf '{"version":"","verified":false}\n'
+ return 0
+ fi
+ local out
+ out="$(_fleet_adapter_call_with_timeout "$type" version "$entry_json" || echo "")"
+ if [ -n "$out" ]; then
+ echo "$out"
+ else
+ printf '{"version":"","verified":false}\n'
+ fi
+}
+
+# Iterate config entries: agents and runtimes, each prefixed "agent\t" or
+# "runtime\t" so callers can distinguish them. Streams JSON lines on stdout.
+fleet_adapter_iter_entries() {
+ local config_path="${1:-$FLEET_CONFIG_PATH}"
+ [ -f "$config_path" ] || return 0
+ python3 - "$config_path" <<'ITER_PY'
+import json, sys
+try:
+ with open(sys.argv[1]) as f:
+ c = json.load(f)
+except Exception:
+ sys.exit(0)
+for a in c.get("agents", []):
+ print("agent\t" + json.dumps(a))
+for r in c.get("runtimes", []):
+ print("runtime\t" + json.dumps(r))
+ITER_PY
+}
+
+# ββ Pretty status row ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# Render a single row given entry json and health json. The adapter mode
+# label (verified | inferred) reflects the adapter's intrinsic category,
+# computed via adapter__verified, not the per-probe verified flag.
+_fleet_adapter_render_row() {
+ local entry="$1" health="$2" atype="$3" inflight="${4:-0}"
+ local mode="unknown"
+ if fleet_adapter_exists "$atype"; then
+ mode="$(adapter_"${atype}"_verified 2>/dev/null || echo unknown)"
+ fi
+ CLR_GREEN="$CLR_GREEN" CLR_RED="$CLR_RED" CLR_YELLOW="$CLR_YELLOW" \
+ CLR_DIM="$CLR_DIM" CLR_CYAN="$CLR_CYAN" CLR_BOLD="$CLR_BOLD" CLR_RESET="$CLR_RESET" \
+ python3 - "$entry" "$health" "$atype" "$inflight" "$mode" <<'ROW_PY'
+import json, os, sys
+e = json.loads(sys.argv[1]) if sys.argv[1] else {}
+h = json.loads(sys.argv[2]) if sys.argv[2] else {}
+atype = sys.argv[3]
+inflight = sys.argv[4] == "1"
+mode = sys.argv[5]
+G=os.environ.get("CLR_GREEN",""); R=os.environ.get("CLR_RED",""); Y=os.environ.get("CLR_YELLOW",""); D=os.environ.get("CLR_DIM",""); C=os.environ.get("CLR_CYAN",""); B=os.environ.get("CLR_BOLD",""); N=os.environ.get("CLR_RESET","")
+status = h.get("status","?")
+ms = h.get("elapsed_ms", 0)
+name = e.get("name","?")
+endpoint = ""
+if atype == "openclaw":
+ endpoint = f"{e.get('host','127.0.0.1')}:{e.get('port','')}"
+elif atype == "http":
+ endpoint = e.get("url","")
+elif atype == "docker":
+ endpoint = f"docker:{e.get('container','')}"
+elif atype == "process":
+ endpoint = f"proc:{e.get('process','')}"
+else:
+ endpoint = e.get("endpoint","")
+def trunc(s, n):
+ s = str(s)
+ return s if len(s) <= n else s[:n-1] + "β¦"
+if mode == "verified":
+ mode_tag = f"{D}verified{N}"
+elif mode == "inferred":
+ mode_tag = f"{Y}inferred{N}"
+else:
+ mode_tag = f"{D}unknown {N}"
+if inflight:
+ icon = f"{C}β{N}"; clr = D
+ status_text = "probing"
+ ms_text = ""
+ mode_tag = D + " " + N
+else:
+ if status == "online":
+ icon = f"{G}β¬’{N}"; clr = G
+ elif status in ("starting","degraded","auth_failed"):
+ icon = f"{Y}β{N}"; clr = Y
+ elif status == "unknown":
+ icon = f"{D}β{N}"; clr = D
+ else:
+ icon = f"{R}⬑{N}"; clr = R
+ status_text = status
+ ms_text = f"{D}{ms}ms{N}" if ms else f"{D}--{N}"
+print(f" {icon} {B}{trunc(name,18):<18}{N} {C}{atype:<9}{N} {trunc(endpoint,32):<32} {clr}{status_text:<11}{N} {mode_tag} {ms_text}")
+ROW_PY
+}
+
+# Compute aggregate footer line for a probed batch.
+_fleet_adapter_render_footer() {
+ local results_dir="$1" total="$2"
+ CLR_GREEN="$CLR_GREEN" CLR_RED="$CLR_RED" CLR_YELLOW="$CLR_YELLOW" \
+ CLR_DIM="$CLR_DIM" CLR_BOLD="$CLR_BOLD" CLR_RESET="$CLR_RESET" \
+ python3 - "$results_dir" "$total" <<'FOOT_PY'
+import json, os, sys
+d, total = sys.argv[1], int(sys.argv[2])
+G=os.environ.get("CLR_GREEN",""); R=os.environ.get("CLR_RED",""); Y=os.environ.get("CLR_YELLOW",""); D=os.environ.get("CLR_DIM",""); B=os.environ.get("CLR_BOLD",""); N=os.environ.get("CLR_RESET","")
+online = degraded = offline = 0
+ms_list = []
+for i in range(total):
+ p = os.path.join(d, f"{i}.json")
+ try:
+ with open(p) as f:
+ h = json.load(f)
+ except Exception:
+ h = {}
+ s = h.get("status","")
+ if s == "online":
+ online += 1
+ elif s in ("degraded","starting","auth_failed"):
+ degraded += 1
+ else:
+ offline += 1
+ if isinstance(h.get("elapsed_ms",0), int) and h.get("elapsed_ms",0) > 0:
+ ms_list.append(h["elapsed_ms"])
+avg = (sum(ms_list)//len(ms_list)) if ms_list else 0
+parts = [f"{G}{online} online{N}"]
+if degraded: parts.append(f"{Y}{degraded} degraded{N}")
+if offline: parts.append(f"{R}{offline} offline{N}")
+parts.append(f"{D}avg {avg}ms{N}")
+print(f" {D}ββ{N} {B}{total} probe" + ("s" if total != 1 else "") + f"{N} " + f" {D}Β·{N} ".join(parts))
+FOOT_PY
+}
+
+# Probe a list of entries (each a JSON line) in parallel and render rows
+# progressively. As each probe completes, the corresponding queued row is
+# overwritten in place with the resolved status. On non TTY output, results
+# are printed in a single pass at the end.
+#
+# Args: each entry json as a separate argument.
+fleet_adapter_probe_parallel() {
+ local n="$#"
+ [ "$n" -eq 0 ] && return 0
+
+ local tmpdir
+ tmpdir="$(mktemp -d -t fleet_probe.XXXXXX)"
+
+ local i=0 entry pids=() entries=()
+ for entry in "$@"; do
+ entries+=("$entry")
+ ( fleet_adapter_health "$entry" > "$tmpdir/$i.json" 2>/dev/null ) &
+ pids+=($!)
+ i=$((i+1))
+ done
+
+ if [ -t 1 ] && [ "${NO_COLOR:-}" = "" ]; then
+ # Print queued placeholder rows so terminals that scroll do not lose context.
+ local atype
+ for ((i=0; i/dev/null; then
+ # Probe finished: render real row.
+ local res atype2
+ res="$(cat "$tmpdir/$i.json" 2>/dev/null)"
+ atype2="$(fleet_adapter_resolve "${entries[$i]}")"
+ printf "\033[%dB\r\033[K" "$i"
+ _fleet_adapter_render_row "${entries[$i]}" "$res" "$atype2" 0
+ printf "\033[%dA" "$((i+1))"
+ done_mask[i]=1
+ else
+ running=$((running+1))
+ # Animate this row's spinner in place.
+ local frame="${frames[$((fi % 10))]}"
+ printf "\033[%dB\r \033[36m%s\033[0m" "$i" "$frame"
+ printf "\033[%dA\r" "$((i+1))"
+ fi
+ fi
+ done
+ fi=$((fi+1))
+ loop_count=$((loop_count+1))
+ sleep 0.07
+ done
+ # Move cursor down past the block.
+ printf "\033[%dB" "$n"
+ # Final pass to ensure any rows missed by the loop are correctly drawn.
+ printf "\033[%dA" "$n"
+ for ((i=0; i/dev/null)"
+ [ -z "$res" ] && res='{"status":"error","code":"","elapsed_ms":0,"verified":false,"message":"no output"}'
+ atype2="$(fleet_adapter_resolve "${entries[$i]}")"
+ printf "\r\033[K"
+ _fleet_adapter_render_row "${entries[$i]}" "$res" "$atype2" 0
+ else
+ printf "\033[1B"
+ fi
+ done
+ else
+ wait "${pids[@]}" 2>/dev/null
+ for ((i=0; i/dev/null)"
+ [ -z "$res" ] && res='{"status":"error","code":"","elapsed_ms":0,"verified":false,"message":"no output"}'
+ atype2="$(fleet_adapter_resolve "${entries[$i]}")"
+ _fleet_adapter_render_row "${entries[$i]}" "$res" "$atype2" 0
+ done
+ fi
+ wait "${pids[@]}" 2>/dev/null
+
+ # Footer summary line.
+ if [ "$n" -gt 0 ]; then
+ _fleet_adapter_render_footer "$tmpdir" "$n"
+ fi
+ rm -rf "$tmpdir"
+}
+
+# Look up a runtime or agent by name from config.
+fleet_runtime_get() {
+ local name="$1" config_path="${2:-$FLEET_CONFIG_PATH}"
+ [ -f "$config_path" ] || return 1
+ python3 - "$config_path" "$name" <<'GET_PY'
+import json, sys
+try:
+ with open(sys.argv[1]) as f:
+ c = json.load(f)
+except Exception:
+ sys.exit(1)
+name = sys.argv[2]
+for r in c.get("runtimes", []):
+ if r.get("name") == name:
+ print(json.dumps(r))
+ sys.exit(0)
+for a in c.get("agents", []):
+ if a.get("name") == name:
+ print(json.dumps(a))
+ sys.exit(0)
+sys.exit(1)
+GET_PY
+}
diff --git a/lib/core/config.sh b/lib/core/config.sh
index e502c84..c46c58d 100644
--- a/lib/core/config.sh
+++ b/lib/core/config.sh
@@ -3,7 +3,7 @@
# Reads ~/.fleet/config.json (or $FLEET_CONFIG) and provides accessor functions.
# shellcheck disable=SC2034
-FLEET_VERSION="3.0.5"
+FLEET_VERSION="4.0.0"
FLEET_CONFIG_PATH="${FLEET_CONFIG:-$HOME/.fleet/config.json}"
FLEET_LOG_FILE="${FLEET_LOG:-$HOME/.fleet/log.jsonl}"
diff --git a/lib/core/policy.sh b/lib/core/policy.sh
new file mode 100644
index 0000000..08edb48
--- /dev/null
+++ b/lib/core/policy.sh
@@ -0,0 +1,140 @@
+#!/bin/bash
+# fleet/lib/core/policy.sh: Optional operator constitution prompt policy.
+# Applies operator-defined rules to dispatched agent tasks.
+
+fleet_policy_apply() {
+ local prompt="$1" agent="${2:-}" task_type="${3:-}" action="${4:-task}"
+ python3 - "$FLEET_CONFIG_PATH" "$agent" "$task_type" "$action" "$prompt" <<'POLICY_PY'
+import json, sys
+
+config_path, agent, task_type, action, prompt = sys.argv[1:6]
+try:
+ with open(config_path) as f:
+ config = json.load(f)
+except Exception:
+ print(prompt)
+ sys.exit(0)
+
+policy = config.get("constitution") or config.get("policy") or {}
+if not isinstance(policy, dict) or not policy.get("enabled", False):
+ print(prompt)
+ sys.exit(0)
+
+only_agents = policy.get("agents") or []
+if only_agents and agent not in only_agents:
+ print(prompt)
+ sys.exit(0)
+
+apply_to = policy.get("applyTo") or policy.get("apply_to") or policy.get("commands") or ["task", "parallel", "steer"]
+if isinstance(apply_to, str):
+ apply_to = [part.strip() for part in apply_to.split(",")]
+apply_to = [str(part).strip().lower() for part in apply_to if str(part).strip()]
+if apply_to and action.lower() not in apply_to and "all" not in apply_to:
+ print(prompt)
+ sys.exit(0)
+
+rules = policy.get("rules") or []
+if isinstance(rules, str):
+ rules = [rules]
+rules = [str(r).strip() for r in rules if str(r).strip()]
+
+prefix = str(policy.get("prefix") or policy.get("prompt") or "Follow the operator constitution before doing this task.").strip()
+title = str(policy.get("title") or "Operator Constitution").strip()
+mode = str(policy.get("mode") or "prepend").strip().lower()
+
+if not rules and not prefix:
+ print(prompt)
+ sys.exit(0)
+
+lines = [title]
+if prefix:
+ lines.append(prefix)
+if agent:
+ lines.append(f"Agent: {agent}")
+if task_type:
+ lines.append(f"Task type: {task_type}")
+if rules:
+ lines.append("Rules:")
+ for i, rule in enumerate(rules, 1):
+ lines.append(f"{i}. {rule}")
+block = "\n".join(lines).strip()
+
+if mode == "append":
+ print(f"{prompt}\n\n{block}")
+else:
+ print(f"{block}\n\nTask:\n{prompt}")
+POLICY_PY
+}
+
+fleet_policy_guard() {
+ local agent="${1:-}" task_type="${2:-}" action="${3:-task}"
+ python3 - "$FLEET_CONFIG_PATH" "$agent" "$task_type" "$action" <<'POLICY_PY'
+import json, sys
+
+config_path, agent, task_type, action = sys.argv[1:5]
+try:
+ with open(config_path) as f:
+ config = json.load(f)
+except Exception:
+ print("ok")
+ sys.exit(0)
+policy = config.get("constitution") or config.get("policy") or {}
+if not isinstance(policy, dict):
+ print("ok")
+ sys.exit(0)
+required = bool(policy.get("required", False) or policy.get("strict", False))
+if not required:
+ print("ok")
+ sys.exit(0)
+apply_to = policy.get("applyTo") or policy.get("apply_to") or policy.get("commands") or ["task", "parallel", "steer"]
+if isinstance(apply_to, str):
+ apply_to = [part.strip() for part in apply_to.split(",")]
+apply_to = [str(part).strip().lower() for part in apply_to if str(part).strip()]
+if apply_to and action.lower() not in apply_to and "all" not in apply_to:
+ print("ok")
+ sys.exit(0)
+only_agents = policy.get("agents") or []
+if only_agents and agent not in only_agents:
+ print(f"constitution required but agent '{agent}' is outside constitution scope")
+ sys.exit(1)
+rules = policy.get("rules") or []
+if isinstance(rules, str):
+ rules = [rules]
+rules = [str(r).strip() for r in rules if str(r).strip()]
+prefix = str(policy.get("prefix") or policy.get("prompt") or "").strip()
+if not policy.get("enabled", False):
+ print("constitution required but disabled")
+ sys.exit(1)
+if not rules and not prefix:
+ print("constitution required but has no rules")
+ sys.exit(1)
+print("ok")
+POLICY_PY
+}
+
+fleet_policy_summary_json() {
+ python3 - "$FLEET_CONFIG_PATH" <<'POLICY_PY'
+import json, sys
+try:
+ with open(sys.argv[1]) as f:
+ config = json.load(f)
+except Exception:
+ print(json.dumps({"enabled": False, "rules": []}))
+ sys.exit(0)
+policy = config.get("constitution") or config.get("policy") or {}
+if not isinstance(policy, dict):
+ policy = {}
+rules = policy.get("rules") or []
+if isinstance(rules, str):
+ rules = [rules]
+print(json.dumps({
+ "enabled": bool(policy.get("enabled", False)),
+ "title": policy.get("title") or "Operator Constitution",
+ "mode": policy.get("mode") or "prepend",
+ "required": bool(policy.get("required", False) or policy.get("strict", False)),
+ "agents": policy.get("agents") or [],
+ "applyTo": policy.get("applyTo") or policy.get("apply_to") or policy.get("commands") or ["task", "parallel", "steer"],
+ "rules": [str(r) for r in rules if str(r).strip()],
+}))
+POLICY_PY
+}
diff --git a/templates/configs/full.json b/templates/configs/full.json
index 6b0ba4c..9527a14 100644
--- a/templates/configs/full.json
+++ b/templates/configs/full.json
@@ -4,55 +4,98 @@
"port": 48391,
"name": "coordinator",
"role": "coordinator",
- "model": "claude-opus-4"
+ "model": "strategic-default"
},
"agents": [
{
"name": "coder",
"port": 48520,
"role": "implementation",
- "model": "codex",
+ "model": "coding-default",
"token": "your-token-here"
},
{
"name": "reviewer",
"port": 48540,
"role": "code-review",
- "model": "codex",
+ "model": "coding-default",
"token": "your-token-here"
},
{
"name": "deployer",
"port": 48560,
"role": "deployment",
- "model": "codex",
+ "model": "coding-default",
"token": "your-token-here"
},
{
"name": "qa",
"port": 48580,
"role": "quality-assurance",
- "model": "codex",
+ "model": "coding-default",
"token": "your-token-here"
},
{
"name": "researcher",
"port": 48600,
"role": "research",
- "model": "codex",
+ "model": "coding-default",
"token": "your-token-here"
}
],
"endpoints": [
- { "name": "website", "url": "https://example.com", "expectedStatus": 200 },
- { "name": "api", "url": "https://api.example.com/health", "expectedStatus": 200 },
- { "name": "docs", "url": "https://docs.example.com", "expectedStatus": 200, "timeout": 10 }
+ {
+ "name": "website",
+ "url": "https://example.com",
+ "expectedStatus": 200
+ },
+ {
+ "name": "api",
+ "url": "https://api.example.com/health",
+ "expectedStatus": 200
+ },
+ {
+ "name": "docs",
+ "url": "https://docs.example.com",
+ "expectedStatus": 200,
+ "timeout": 10
+ }
+ ],
+ "runtimes": [
+ {
+ "name": "billing-api",
+ "adapter": "http",
+ "url": "https://billing.example.com/health",
+ "expectedStatus": 200
+ },
+ {
+ "name": "postgres",
+ "adapter": "docker",
+ "container": "postgres"
+ },
+ {
+ "name": "tailscaled",
+ "adapter": "process",
+ "process": "tailscaled"
+ }
],
"repos": [
- { "name": "frontend", "repo": "myorg/frontend" },
- { "name": "backend", "repo": "myorg/backend" },
- { "name": "mobile", "repo": "myorg/mobile" },
- { "name": "infra", "repo": "myorg/infrastructure" }
+ {
+ "name": "frontend",
+ "repo": "myorg/frontend"
+ },
+ {
+ "name": "backend",
+ "repo": "myorg/backend"
+ },
+ {
+ "name": "mobile",
+ "repo": "myorg/mobile"
+ },
+ {
+ "name": "infra",
+ "repo": "myorg/infrastructure"
+ }
],
"services": [
"openclaw-gateway",
@@ -60,8 +103,26 @@
"openclaw-gateway-reviewer"
],
"linear": {
- "teams": ["PROJ"],
+ "teams": [
+ "PROJ"
+ ],
"apiKeyEnv": "LINEAR_API_KEY"
},
- "skillsDir": ""
+ "skillsDir": "",
+ "constitution": {
+ "enabled": true,
+ "title": "Operator Constitution",
+ "mode": "prepend",
+ "rules": [
+ "Read project instructions before editing files",
+ "Run the smallest meaningful verification before reporting completion",
+ "Do not rewrite shared git history unless the operator explicitly declares an emergency"
+ ],
+ "applyTo": [
+ "task",
+ "parallel",
+ "steer"
+ ],
+ "required": false
+ }
}
diff --git a/templates/configs/minimal.json b/templates/configs/minimal.json
index 0afa0e1..83e151d 100644
--- a/templates/configs/minimal.json
+++ b/templates/configs/minimal.json
@@ -7,6 +7,17 @@
"model": "default"
},
"agents": [],
+ "runtimes": [],
"endpoints": [],
- "repos": []
+ "repos": [],
+ "constitution": {
+ "enabled": false,
+ "rules": [],
+ "applyTo": [
+ "task",
+ "parallel",
+ "steer"
+ ],
+ "required": false
+ }
}
diff --git a/tests/test_cli.sh b/tests/test_cli.sh
index 009e719..58cf101 100755
--- a/tests/test_cli.sh
+++ b/tests/test_cli.sh
@@ -6,6 +6,10 @@ FLEET="$(cd "$(dirname "$0")/.." && pwd)/bin/fleet"
PASS=0
FAIL=0
+# Use homebrew bash on macOS; fall back to whatever bash is on PATH
+FBASH=/opt/homebrew/bin/bash
+[ -x "$FBASH" ] || FBASH=bash
+
assert_ok() {
local desc="$1"
shift
@@ -23,7 +27,7 @@ assert_output_contains() {
shift 2
local output
output=$("$@" 2>&1)
- if echo "$output" | grep -q "$expected"; then
+ if grep -F -q -- "$expected" <<< "$output"; then
echo " β
$desc"
((PASS++))
else
@@ -32,6 +36,162 @@ assert_output_contains() {
fi
}
+_start_policy_capture_server() {
+ _POLICY_SERVER_DIR=$(mktemp -d /tmp/fleet-policy-server.XXXXXX)
+ _POLICY_PORT_FILE="$_POLICY_SERVER_DIR/port"
+ _POLICY_CAPTURE_FILE="$_POLICY_SERVER_DIR/capture.json"
+ python3 - "$_POLICY_PORT_FILE" "$_POLICY_CAPTURE_FILE" <<'PYSERVER' &
+import sys
+from http.server import BaseHTTPRequestHandler, HTTPServer
+port_file, capture_file = sys.argv[1:3]
+class Handler(BaseHTTPRequestHandler):
+ def do_POST(self):
+ size = int(self.headers.get("Content-Length", "0"))
+ body = self.rfile.read(size).decode()
+ with open(capture_file, "w") as f:
+ f.write(body)
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(b'{"choices":[{"message":{"content":"ok"}}]}')
+ def log_message(self, *_args):
+ return
+server = HTTPServer(("127.0.0.1", 0), Handler)
+with open(port_file, "w") as f:
+ f.write(str(server.server_port))
+server.handle_request()
+PYSERVER
+ _POLICY_SERVER_PID=$!
+ for _i in $(seq 1 50); do
+ [ -s "$_POLICY_PORT_FILE" ] && return 0
+ sleep 0.1
+ done
+ return 1
+}
+
+_wait_policy_capture() {
+ for _i in $(seq 1 50); do
+ [ -s "$_POLICY_CAPTURE_FILE" ] && return 0
+ sleep 0.1
+ done
+ return 1
+}
+
+_cleanup_policy_capture_server() {
+ [ -n "${_POLICY_SERVER_PID:-}" ] && kill "$_POLICY_SERVER_PID" 2>/dev/null || true
+ [ -n "${_POLICY_SERVER_PID:-}" ] && wait "$_POLICY_SERVER_PID" 2>/dev/null || true
+ [ -n "${_POLICY_SERVER_DIR:-}" ] && rm -rf "$_POLICY_SERVER_DIR"
+}
+
+assert_task_policy_dispatch() {
+ local cfg port
+ _start_policy_capture_server || return 1
+ port=$(cat "$_POLICY_PORT_FILE")
+ cfg="$_POLICY_SERVER_DIR/config.json"
+ cat > "$cfg" </dev/null
+ _wait_policy_capture || { _cleanup_policy_capture_server; return 1; }
+ python3 - "$_POLICY_CAPTURE_FILE" <<'PYCHECK'
+import json, sys
+body = json.load(open(sys.argv[1]))
+content = body["messages"][0]["content"]
+assert "Team Constitution" in content
+assert "Never rewrite shared history" in content
+assert "Task:\nfix tests" in content
+PYCHECK
+ local rc=$?
+ _cleanup_policy_capture_server
+ return $rc
+}
+
+assert_parallel_policy_dispatch() {
+ local cfg port
+ _start_policy_capture_server || return 1
+ port=$(cat "$_POLICY_PORT_FILE")
+ cfg="$_POLICY_SERVER_DIR/config.json"
+ cat > "$cfg" </dev/null
+ _wait_policy_capture || { _cleanup_policy_capture_server; return 1; }
+ python3 - "$_POLICY_CAPTURE_FILE" <<'PYCHECK'
+import json, sys
+body = json.load(open(sys.argv[1]))
+content = body["messages"][0]["content"]
+assert "Team Constitution" in content
+assert "Never rewrite shared history" in content
+assert "Task:\nImplementation: implement login" in content
+PYCHECK
+ local rc=$?
+ _cleanup_policy_capture_server
+ return $rc
+}
+
+assert_steer_policy_dispatch() {
+ local cfg port
+ _start_policy_capture_server || return 1
+ port=$(cat "$_POLICY_PORT_FILE")
+ cfg="$_POLICY_SERVER_DIR/config.json"
+ cat > "$cfg" </dev/null
+ _wait_policy_capture || { _cleanup_policy_capture_server; return 1; }
+ python3 - "$_POLICY_CAPTURE_FILE" <<'PYCHECK'
+import json, sys
+body = json.load(open(sys.argv[1]))
+content = body["messages"][0]["content"]
+assert "Team Constitution" in content
+assert "Never rewrite shared history" in content
+assert "Task:\ntighten review" in content
+PYCHECK
+ local rc=$?
+ _cleanup_policy_capture_server
+ return $rc
+}
+
+assert_required_policy_blocks_task() {
+ local cfg port
+ _start_policy_capture_server || return 1
+ port=$(cat "$_POLICY_PORT_FILE")
+ cfg="$_POLICY_SERVER_DIR/config.json"
+ cat > "$cfg" </dev/null 2>&1; then
+ _cleanup_policy_capture_server
+ return 1
+ fi
+ if [ -e "$_POLICY_CAPTURE_FILE" ]; then
+ _cleanup_policy_capture_server
+ return 1
+ fi
+ _cleanup_policy_capture_server
+ return 0
+}
+
+assert_required_policy_blocks_steer() {
+ local cfg port
+ _start_policy_capture_server || return 1
+ port=$(cat "$_POLICY_PORT_FILE")
+ cfg="$_POLICY_SERVER_DIR/config.json"
+ cat > "$cfg" </dev/null 2>&1; then
+ _cleanup_policy_capture_server
+ return 1
+ fi
+ if [ -e "$_POLICY_CAPTURE_FILE" ]; then
+ _cleanup_policy_capture_server
+ return 1
+ fi
+ _cleanup_policy_capture_server
+ return 0
+}
+
echo ""
echo "Fleet CLI Tests"
echo "βββββββββββββββ"
@@ -39,21 +199,21 @@ echo "βββββββββββββββ"
echo ""
echo "Syntax"
FLEET_ROOT="$(cd "$(dirname "$FLEET")/.." && pwd)"
-for f in "$FLEET" "$FLEET_ROOT"/lib/core/*.sh "$FLEET_ROOT"/lib/commands/*.sh; do
- [ -f "$f" ] && assert_ok "syntax: $(basename "$f")" bash -n "$f"
+for f in "$FLEET" "$FLEET_ROOT"/lib/core/*.sh "$FLEET_ROOT"/lib/adapters/*.sh "$FLEET_ROOT"/lib/commands/*.sh; do
+ [ -f "$f" ] && assert_ok "syntax: $(basename "$f")" "$FBASH" -n "$f"
done
echo ""
echo "Basic Commands"
-assert_ok "help exits 0" bash "$FLEET" help
-assert_ok "--version exits 0" bash "$FLEET" --version
-assert_output_contains "version format" "fleet v" bash "$FLEET" --version
-assert_output_contains "help contains commands" "fleet health" bash "$FLEET" help
+assert_ok "help exits 0" "$FBASH" "$FLEET" help
+assert_ok "--version exits 0" "$FBASH" "$FLEET" --version
+assert_output_contains "version format" "fleet v" "$FBASH" "$FLEET" --version
+assert_output_contains "help contains commands" "fleet health" "$FBASH" "$FLEET" help
echo ""
echo "Config"
# Health gracefully falls back to defaults when no config
-FLEET_CONFIG=/nonexistent/path assert_ok "health without config works" bash "$FLEET" health
+FLEET_CONFIG=/nonexistent/path assert_ok "health without config works" "$FBASH" "$FLEET" health
echo ""
echo "JSON Validation"
@@ -63,19 +223,65 @@ for f in "$FLEET_ROOT"/examples/*/config.json "$FLEET_ROOT"/templates/configs/*.
fi
done
+echo ""
+echo "Operator Constitution"
+assert_output_contains "help contains policy" "fleet policy" "$FBASH" "$FLEET" help
+_TMP_POLICY_CFG=$(mktemp /tmp/fleet-policy-cfg.XXXXXX.json)
+cat > "$_TMP_POLICY_CFG" <<'CFGDATA'
+{"workspace":"~/workspace","agents":[{"name":"coder","port":48520}],"constitution":{"enabled":true,"title":"Team Constitution","rules":["Never rewrite shared history","Run verification before completion"]}}
+CFGDATA
+assert_output_contains "policy show lists configured rule" "Never rewrite shared history" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG' '$FLEET' policy"
+assert_output_contains "policy preview injects rule" "Run verification before completion" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG' '$FLEET' policy preview coder 'fix tests' --type code"
+assert_ok "policy apply prepends task" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG'; source '$FLEET_ROOT/lib/core/config.sh'; source '$FLEET_ROOT/lib/core/policy.sh'; out=\$(fleet_policy_apply 'fix tests' coder code); echo \"\$out\" | grep -q 'Team Constitution'; echo \"\$out\" | grep -q 'Task:'"
+cat > "$_TMP_POLICY_CFG" <<'CFGDATA'
+{"workspace":"~/workspace","agents":[{"name":"coder","port":48520}],"constitution":{"enabled":false,"rules":["Should not appear"]}}
+CFGDATA
+assert_ok "disabled policy leaves prompt unchanged" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG'; source '$FLEET_ROOT/lib/core/config.sh'; source '$FLEET_ROOT/lib/core/policy.sh'; out=\$(fleet_policy_apply 'fix tests' coder code); [ \"\$out\" = 'fix tests' ]"
+assert_ok "policy enable command updates config" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG' '$FLEET' policy enable >/dev/null; python3 -c 'import json; d=json.load(open(\"$_TMP_POLICY_CFG\")); assert d[\"constitution\"][\"enabled\"] is True'"
+assert_ok "policy add command appends rule" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG' '$FLEET' policy add 'Require local tests' >/dev/null; python3 -c 'import json; d=json.load(open(\"$_TMP_POLICY_CFG\")); assert \"Require local tests\" in d[\"constitution\"][\"rules\"]'"
+assert_ok "policy rm command removes rule" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG' '$FLEET' policy rm 1 >/dev/null; python3 -c 'import json; d=json.load(open(\"$_TMP_POLICY_CFG\")); assert \"Should not appear\" not in d[\"constitution\"][\"rules\"]'"
+assert_ok "policy title command updates title" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG' '$FLEET' policy title 'Team Rules' >/dev/null; python3 -c 'import json; d=json.load(open(\"$_TMP_POLICY_CFG\")); assert d[\"constitution\"][\"title\"] == \"Team Rules\"'"
+assert_ok "policy scope command updates apply list" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG' '$FLEET' policy scope task,parallel,steer >/dev/null; python3 -c 'import json; d=json.load(open(\"$_TMP_POLICY_CFG\")); assert d[\"constitution\"][\"applyTo\"] == [\"task\", \"parallel\", \"steer\"]'"
+assert_ok "policy require command enables required mode" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG' '$FLEET' policy require >/dev/null; python3 -c 'import json; d=json.load(open(\"$_TMP_POLICY_CFG\")); assert d[\"constitution\"][\"required\"] is True; assert d[\"constitution\"][\"enabled\"] is True'"
+assert_ok "policy optional command disables required mode" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG' '$FLEET' policy optional >/dev/null; python3 -c 'import json; d=json.load(open(\"$_TMP_POLICY_CFG\")); assert d[\"constitution\"][\"required\"] is False'"
+assert_ok "policy clear command empties rules" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG' '$FLEET' policy clear >/dev/null; python3 -c 'import json; d=json.load(open(\"$_TMP_POLICY_CFG\")); assert d[\"constitution\"][\"rules\"] == []'"
+cat > "$_TMP_POLICY_CFG" <<'CFGDATA'
+{"workspace":"~/workspace","agents":[{"name":"coder","port":48520}],"constitution":"bad"}
+CFGDATA
+assert_ok "policy commands normalize malformed constitution" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_POLICY_CFG' '$FLEET' policy add 'Require local tests' >/dev/null; python3 -c 'import json; d=json.load(open(\"$_TMP_POLICY_CFG\")); assert isinstance(d[\"constitution\"], dict); assert d[\"constitution\"][\"rules\"] == [\"Require local tests\"]'"
+assert_ok "task dispatch sends policy injected prompt" assert_task_policy_dispatch
+assert_ok "parallel dispatch sends policy injected prompt" assert_parallel_policy_dispatch
+assert_ok "steer dispatch sends policy injected prompt" assert_steer_policy_dispatch
+assert_ok "required policy blocks task without rules" assert_required_policy_blocks_task
+assert_ok "required policy blocks steer without rules" assert_required_policy_blocks_steer
+rm -f "$_TMP_POLICY_CFG"
+
echo ""
echo "Trust Engine (v3)"
-assert_ok "trust.sh syntax" bash -n "$FLEET_ROOT/lib/core/trust.sh"
-assert_ok "trust command syntax" bash -n "$FLEET_ROOT/lib/commands/trust.sh"
-assert_ok "score command syntax" bash -n "$FLEET_ROOT/lib/commands/score.sh"
-assert_ok "trust help exits 0" bash "$FLEET" trust --help
-assert_ok "score help exits 0" bash "$FLEET" score --help
-assert_output_contains "help contains trust" "fleet trust" bash "$FLEET" help
-assert_output_contains "help contains score" "fleet score" bash "$FLEET" help
+assert_ok "trust.sh syntax" "$FBASH" -n "$FLEET_ROOT/lib/core/trust.sh"
+assert_ok "trust command syntax" "$FBASH" -n "$FLEET_ROOT/lib/commands/trust.sh"
+assert_ok "score command syntax" "$FBASH" -n "$FLEET_ROOT/lib/commands/score.sh"
+assert_ok "trust help exits 0" "$FBASH" "$FLEET" trust --help
+assert_ok "score help exits 0" "$FBASH" "$FLEET" score --help
+assert_output_contains "help contains trust" "fleet trust" "$FBASH" "$FLEET" help
+assert_output_contains "help contains score" "fleet score" "$FBASH" "$FLEET" help
# trust with no log: verify graceful exit
-FLEET_LOG=/nonexistent/log.jsonl assert_ok "trust with no log" bash "$FLEET" trust
-FLEET_LOG=/nonexistent/log.jsonl assert_ok "score with no log" bash "$FLEET" score
+FLEET_LOG=/nonexistent/log.jsonl assert_ok "trust with no log" "$FBASH" "$FLEET" trust
+FLEET_LOG=/nonexistent/log.jsonl assert_ok "score with no log" "$FBASH" "$FLEET" score
# trust with minimal synthetic log
_TMP_LOG=$(mktemp /tmp/fleet-test-log.XXXXXX.jsonl)
@@ -87,29 +293,258 @@ cat > "$_TMP_LOG" <<'LOGDATA'
LOGDATA
_EXAMPLE_CFG="$FLEET_ROOT/examples/solo-empire/config.json"
-FLEET_CONFIG="$_EXAMPLE_CFG" FLEET_LOG="$_TMP_LOG" assert_ok "trust with log data" bash "$FLEET" trust
-FLEET_CONFIG="$_EXAMPLE_CFG" FLEET_LOG="$_TMP_LOG" assert_output_contains "trust shows coder" "coder" bash "$FLEET" trust
-FLEET_CONFIG="$_EXAMPLE_CFG" FLEET_LOG="$_TMP_LOG" assert_ok "score with specific agent" bash "$FLEET" score coder
-FLEET_CONFIG="$_EXAMPLE_CFG" FLEET_LOG="$_TMP_LOG" assert_output_contains "score shows percentage" "%" bash "$FLEET" score coder
-FLEET_CONFIG="$_EXAMPLE_CFG" FLEET_LOG="$_TMP_LOG" assert_ok "trust json mode" bash "$FLEET" trust --json
+FLEET_CONFIG="$_EXAMPLE_CFG" FLEET_LOG="$_TMP_LOG" assert_ok "trust with log data" "$FBASH" "$FLEET" trust
+FLEET_CONFIG="$_EXAMPLE_CFG" FLEET_LOG="$_TMP_LOG" assert_output_contains "trust shows coder" "coder" "$FBASH" "$FLEET" trust
+FLEET_CONFIG="$_EXAMPLE_CFG" FLEET_LOG="$_TMP_LOG" assert_ok "score with specific agent" "$FBASH" "$FLEET" score coder
+FLEET_CONFIG="$_EXAMPLE_CFG" FLEET_LOG="$_TMP_LOG" assert_output_contains "score shows percentage" "%" "$FBASH" "$FLEET" score coder
+FLEET_CONFIG="$_EXAMPLE_CFG" FLEET_LOG="$_TMP_LOG" assert_ok "trust json mode" "$FBASH" "$FLEET" trust --json
FLEET_CONFIG="$_EXAMPLE_CFG" FLEET_LOG="$_TMP_LOG" \
assert_ok "trust json is valid JSON" \
- bash -c "FLEET_CONFIG=\"$_EXAMPLE_CFG\" FLEET_LOG=\"$_TMP_LOG\" bash \"$FLEET\" trust --json | python3 -c 'import json,sys; json.load(sys.stdin)'"
+ "$FBASH" -c "FLEET_CONFIG=\"$_EXAMPLE_CFG\" FLEET_LOG=\"$_TMP_LOG\" \"$FBASH\" \"$FLEET\" trust --json | python3 -c 'import json,sys; json.load(sys.stdin)'"
rm -f "$_TMP_LOG"
# update command
-assert_ok "update.sh syntax" bash -n "$FLEET_ROOT/lib/commands/update.sh"
-assert_ok "update help exits 0" bash "$FLEET" update --help
-assert_output_contains "help contains update" "fleet update" bash "$FLEET" help
-assert_ok "update check exits 0 with no network" bash -c "
- FLEET_STATE_DIR=\$(mktemp -d) bash \"$FLEET\" update --check 2>/dev/null; true
+assert_ok "update.sh syntax" "$FBASH" -n "$FLEET_ROOT/lib/commands/update.sh"
+assert_ok "update help exits 0" "$FBASH" "$FLEET" update --help
+assert_output_contains "help contains update" "fleet update" "$FBASH" "$FLEET" help
+assert_ok "update check exits 0 with no network" "$FBASH" -c "
+ FLEET_STATE_DIR=\$(mktemp -d) \"$FBASH\" \"$FLEET\" update --check 2>/dev/null; true
"
-assert_ok "update install dir detection" bash -c "
+assert_ok "update install dir detection" "$FBASH" -c "
type fleet >/dev/null 2>&1 || export PATH=\"\$PATH:\$(dirname \"$FLEET\")\"
true
"
+echo ""
+echo "Adapter Layer (v4)"
+assert_ok "adapters.sh (core) syntax" "$FBASH" -n "$FLEET_ROOT/lib/core/adapters.sh"
+assert_ok "adapters.sh (command) syntax" "$FBASH" -n "$FLEET_ROOT/lib/commands/adapters.sh"
+assert_ok "runtime.sh (command) syntax" "$FBASH" -n "$FLEET_ROOT/lib/commands/runtime.sh"
+for _a in openclaw http docker process; do
+ assert_ok "adapter/$_a.sh syntax" "$FBASH" -n "$FLEET_ROOT/lib/adapters/$_a.sh"
+done
+
+# each built-in adapter must define the full six-function contract
+for _a in openclaw http docker process; do
+ _af="$FLEET_ROOT/lib/adapters/$_a.sh"
+ for _fn in describe verified required health info version; do
+ assert_ok "adapter $_a: defines adapter_${_a}_${_fn}" grep -q "adapter_${_a}_${_fn}()" "$_af"
+ done
+done
+
+# fleet adapters command
+assert_ok "fleet adapters exits 0" "$FBASH" "$FLEET" adapters
+assert_output_contains "fleet adapters lists openclaw" "openclaw" "$FBASH" "$FLEET" adapters
+assert_output_contains "fleet adapters lists http" "http" "$FBASH" "$FLEET" adapters
+assert_output_contains "fleet adapters lists docker" "docker" "$FBASH" "$FLEET" adapters
+assert_output_contains "fleet adapters lists process" "process" "$FBASH" "$FLEET" adapters
+
+# fleet runtime subcommands
+assert_ok "fleet runtime (no args) exits 0" "$FBASH" "$FLEET" runtime
+assert_ok "fleet runtimes alias exits 0" "$FBASH" "$FLEET" runtimes
+assert_output_contains "help contains fleet adapters" "fleet adapters" "$FBASH" "$FLEET" help
+assert_output_contains "help contains fleet runtime" "fleet runtime" "$FBASH" "$FLEET" help
+
+# fleet runtime list shows a helpful error (not a crash) when config is missing
+FLEET_CONFIG=/nonexistent/path assert_output_contains "runtime list no config: prompts init" "fleet init" "$FBASH" "$FLEET" runtime list
+
+# cross-runtime example JSON
+assert_ok "cross-runtime example valid JSON" \
+ python3 -c "import json; d=json.load(open('$FLEET_ROOT/examples/cross-runtime/config.json')); assert 'runtimes' in d"
+assert_ok "cross-runtime example has all adapter types" python3 -c "
+import json
+d = json.load(open('$FLEET_ROOT/examples/cross-runtime/config.json'))
+types = {r['adapter'] for r in d['runtimes']}
+assert 'openclaw' in types and 'http' in types and 'docker' in types and 'process' in types, types
+"
+
+# runtime lifecycle: add, list, test, rm using a temp config
+_TMP_CFG=$(mktemp /tmp/fleet-test-cfg.XXXXXX.json)
+cat > "$_TMP_CFG" <<'CFGDATA'
+{"workspace":"~/workspace","agents":[],"runtimes":[]}
+CFGDATA
+
+assert_ok "runtime add (process adapter)" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_CFG' \"$FBASH\" '$FLEET' runtime add test-proc process --process bash"
+assert_ok "runtime list shows added entry" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_CFG' \"$FBASH\" '$FLEET' runtime list 2>&1 | grep -q 'test-proc'"
+assert_ok "runtime rm removes entry" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_CFG' \"$FBASH\" '$FLEET' runtime rm test-proc"
+assert_ok "runtime list empty after rm" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_CFG' \"$FBASH\" '$FLEET' runtime list 2>&1; true"
+assert_ok "runtime rm nonexistent exits nonzero" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_CFG' \"$FBASH\" '$FLEET' runtime rm no-such-runtime 2>/dev/null; [ \$? -ne 0 ]"
+
+rm -f "$_TMP_CFG"
+
+echo ""
+echo "Adapter Layer (v4) deeper checks"
+
+# Adapter resolution via "type" field as well as "adapter"
+assert_ok "resolve via adapter field" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; r=\$(fleet_adapter_resolve '{\"adapter\":\"http\",\"url\":\"x\"}'); [ \"\$r\" = http ]"
+assert_ok "resolve via type field fallback" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; r=\$(fleet_adapter_resolve '{\"type\":\"docker\",\"container\":\"x\"}'); [ \"\$r\" = docker ]"
+assert_ok "resolve default openclaw" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; r=\$(fleet_adapter_resolve '{\"port\":48391}'); [ \"\$r\" = openclaw ]"
+
+# Validation: ok and missing field cases
+assert_ok "validate ok with full http entry" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; r=\$(fleet_adapter_validate '{\"adapter\":\"http\",\"url\":\"https://x\"}'); [ \"\$r\" = ok ]"
+assert_ok "validate fails on missing http url" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; r=\$(fleet_adapter_validate '{\"adapter\":\"http\"}'); [ \"\$r\" = url ]"
+assert_ok "validate fails on missing docker container" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; r=\$(fleet_adapter_validate '{\"adapter\":\"docker\"}'); [ \"\$r\" = container ]"
+assert_ok "validate fails on missing process pattern" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; r=\$(fleet_adapter_validate '{\"adapter\":\"process\"}'); [ \"\$r\" = process ]"
+assert_ok "validate flags unknown adapter" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; r=\$(fleet_adapter_validate '{\"adapter\":\"nope\"}' 2>/dev/null); echo \"\$r\" | grep -q missing_adapter"
+
+# adapter health emits valid JSON for every built-in adapter, even with empty input
+for _a in openclaw http docker process; do
+ assert_ok "adapter $_a health emits valid JSON for empty entry" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; fleet_adapter_health '{\"adapter\":\"$_a\"}' | python3 -c 'import json,sys; json.load(sys.stdin)'"
+done
+
+# OpenClaw adapter fallback uses authenticated model endpoint when health is not available
+_TMP_OPENCLAW_SERVER=$(mktemp /tmp/fleet-openclaw-server.XXXXXX.py)
+cat > "$_TMP_OPENCLAW_SERVER" <<'PY_SERVER'
+from http.server import BaseHTTPRequestHandler, HTTPServer
+import os
+class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path == "/health":
+ self.send_response(404)
+ self.end_headers()
+ elif self.path == "/v1/models":
+ self.send_response(401)
+ self.end_headers()
+ else:
+ self.send_response(404)
+ self.end_headers()
+ def log_message(self, *args):
+ pass
+HTTPServer(("127.0.0.1", int(os.environ["PORT"])), Handler).serve_forever()
+PY_SERVER
+_OPENCLAW_PORT=39292
+PORT="$_OPENCLAW_PORT" python3 "$_TMP_OPENCLAW_SERVER" &
+_OPENCLAW_PID=$!
+sleep 1
+assert_ok "openclaw token fallback reaches auth endpoint" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; fleet_adapter_health '{\"adapter\":\"openclaw\",\"port\":$_OPENCLAW_PORT,\"token\":\"x\"}' | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d[\"status\"]==\"auth_failed\", d; assert d[\"verified\"] is True, d'"
+kill "$_OPENCLAW_PID" 2>/dev/null || true
+rm -f "$_TMP_OPENCLAW_SERVER"
+
+# Probe parallel handles zero entries cleanly
+assert_ok "probe_parallel zero entries no error" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; fleet_adapter_probe_parallel; true"
+
+# Runtime add idempotency: add same name twice and verify single entry
+_TMP_CFG2=$(mktemp /tmp/fleet-test-cfg-idem.XXXXXX.json)
+cat > "$_TMP_CFG2" <<'CFGDATA'
+{"workspace":"~/workspace","agents":[],"runtimes":[]}
+CFGDATA
+assert_ok "runtime add (first time)" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_CFG2' \"$FBASH\" '$FLEET' runtime add edge process --process bash"
+assert_ok "runtime add (idempotent: same name updates)" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_CFG2' \"$FBASH\" '$FLEET' runtime add edge process --process zsh"
+assert_ok "single runtime entry after re-add" \
+ python3 -c "import json; d=json.load(open('$_TMP_CFG2')); rs=[r for r in d['runtimes'] if r['name']=='edge']; assert len(rs)==1, len(rs)"
+assert_ok "second add updates process pattern" \
+ python3 -c "import json; d=json.load(open('$_TMP_CFG2')); rs=[r for r in d['runtimes'] if r['name']=='edge']; assert rs[0]['process']=='zsh', rs[0]"
+
+# Runtime add refuses agent-name conflict
+cat > "$_TMP_CFG2" <<'CFGDATA'
+{"workspace":"~/workspace","agents":[{"name":"coder","port":48520}],"runtimes":[]}
+CFGDATA
+assert_ok "runtime add rejects existing agent name" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_CFG2' \"$FBASH\" '$FLEET' runtime add coder process --process bash 2>/dev/null; [ \$? -ne 0 ]"
+
+# Runtime add validates required field at the CLI layer
+cat > "$_TMP_CFG2" <<'CFGDATA'
+{"workspace":"~/workspace","agents":[],"runtimes":[]}
+CFGDATA
+assert_ok "runtime add http without url is rejected" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_CFG2' \"$FBASH\" '$FLEET' runtime add billing http 2>/dev/null; [ \$? -ne 0 ]"
+
+# fleet runtime test on unknown name returns non-zero
+assert_ok "runtime test unknown name fails" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_CFG2' \"$FBASH\" '$FLEET' runtime test no-such 2>/dev/null; [ \$? -ne 0 ]"
+
+assert_ok "runtime parser respects match full false" \
+ "$FBASH" -c "source '$FLEET_ROOT/lib/commands/runtime.sh'; entry=\$(_runtime_parse_args edge process --process bash --match-full=false); ENTRY=\"\$entry\" python3 -c 'import json,os; d=json.loads(os.environ[\"ENTRY\"]); assert d[\"matchFull\"] is False'"
+
+# Custom adapter directory: drop a fake adapter and verify it loads
+_TMP_ADAPTERS_DIR=$(mktemp -d /tmp/fleet-test-adapters.XXXXXX)
+cat > "$_TMP_ADAPTERS_DIR/dummy.sh" <<'ADAPTER'
+adapter_dummy_describe() { echo "Dummy adapter for tests"; }
+adapter_dummy_verified() { echo "verified"; }
+adapter_dummy_required() { echo "magic"; }
+adapter_dummy_health() {
+ echo '{"status":"online","code":"DUMMY","elapsed_ms":1,"verified":true,"message":""}'
+}
+adapter_dummy_info() {
+ echo '{"name":"dummy","type":"dummy","endpoint":"none","verified":true}'
+}
+adapter_dummy_version() {
+ echo '{"version":"1.0","verified":true}'
+}
+ADAPTER
+assert_ok "user adapter directory loads custom adapter" \
+ "$FBASH" -c "FLEET_ADAPTERS_DIR='$_TMP_ADAPTERS_DIR' \"$FBASH\" '$FLEET' adapters 2>&1 | grep -q dummy"
+assert_ok "user adapter validation requires field" \
+ "$FBASH" -c "FLEET_ADAPTERS_DIR='$_TMP_ADAPTERS_DIR' FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; r=\$(fleet_adapter_validate '{\"adapter\":\"dummy\"}'); [ \"\$r\" = magic ]"
+assert_ok "user adapter health JSON valid" \
+ "$FBASH" -c "FLEET_ADAPTERS_DIR='$_TMP_ADAPTERS_DIR' FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; fleet_adapter_health '{\"adapter\":\"dummy\",\"magic\":\"x\"}' | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d[\"status\"]==\"online\"'"
+cat > "$_TMP_ADAPTERS_DIR/slow.sh" <<'ADAPTER'
+adapter_slow_describe() { echo "Slow adapter for tests"; }
+adapter_slow_verified() { echo "verified"; }
+adapter_slow_required() { echo ""; }
+adapter_slow_health() { sleep 2; echo '{"status":"online","code":"SLOW","elapsed_ms":2000,"verified":true,"message":""}'; }
+adapter_slow_info() { echo '{"type":"slow","verified":true}'; }
+adapter_slow_version() { echo '{"version":"1.0","verified":true}'; }
+ADAPTER
+assert_ok "adapter health timeout returns JSON error" \
+ "$FBASH" -c "FLEET_ADAPTER_TIMEOUT=1 FLEET_ADAPTERS_DIR='$_TMP_ADAPTERS_DIR' FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; fleet_adapter_health '{\"adapter\":\"slow\"}' | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d[\"status\"]==\"error\"; assert \"timed out\" in d[\"message\"]'"
+rm -rf "$_TMP_ADAPTERS_DIR"
+
+# fleet_runtime_get returns agent or runtime by name
+cat > "$_TMP_CFG2" <<'CFGDATA'
+{"workspace":"~/workspace","agents":[{"name":"coder","port":48520}],"runtimes":[{"name":"db","adapter":"docker","container":"postgres"}]}
+CFGDATA
+assert_ok "runtime_get returns agent" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/config.sh'; FLEET_CONFIG='$_TMP_CFG2' FLEET_CONFIG_PATH='$_TMP_CFG2'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; r=\$(fleet_runtime_get coder '$_TMP_CFG2'); echo \"\$r\" | grep -q '\"name\": \"coder\"'"
+assert_ok "runtime_get returns runtime" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/config.sh'; FLEET_CONFIG='$_TMP_CFG2' FLEET_CONFIG_PATH='$_TMP_CFG2'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; r=\$(fleet_runtime_get db '$_TMP_CFG2'); echo \"\$r\" | grep -q '\"name\": \"db\"'"
+assert_ok "runtime_get unknown returns failure" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/config.sh'; FLEET_CONFIG='$_TMP_CFG2' FLEET_CONFIG_PATH='$_TMP_CFG2'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; fleet_runtime_get nope '$_TMP_CFG2' 2>/dev/null; [ \$? -ne 0 ]"
+
+# fleet adapters table headers and legend present
+assert_output_contains "fleet adapters shows mode column" "MODE" "$FBASH" "$FLEET" adapters
+assert_output_contains "fleet adapters shows legend" "Legend" "$FBASH" "$FLEET" adapters
+
+# Probe row and footer renderers do not crash
+for _a in openclaw http docker process; do
+ assert_ok "probe row renders for $_a" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/output.sh'; source '$FLEET_ROOT/lib/core/adapters.sh'; fleet_adapter_load_all; _fleet_adapter_render_row '{\"name\":\"x\",\"adapter\":\"$_a\"}' '{\"status\":\"offline\",\"elapsed_ms\":1,\"verified\":false}' '$_a' 0 >/dev/null"
+done
+_TMP_FOOT=$(mktemp -d /tmp/fleet-test-foot.XXXXXX)
+echo '{"status":"online","elapsed_ms":12,"verified":true}' > "$_TMP_FOOT/0.json"
+echo '{"status":"offline","elapsed_ms":3,"verified":true}' > "$_TMP_FOOT/1.json"
+assert_ok "probe footer renders" \
+ "$FBASH" -c "FLEET_ROOT='$FLEET_ROOT'; source '$FLEET_ROOT/lib/core/adapters.sh'; _fleet_adapter_render_footer '$_TMP_FOOT' 2 | grep -q online"
+rm -rf "$_TMP_FOOT"
+
+# Non TTY output should stay machine friendly and contain no ANSI escapes
+cat > "$_TMP_CFG2" <<'CFGDATA'
+{"workspace":"~/workspace","agents":[],"runtimes":[{"name":"shell","adapter":"process","process":"zsh"}]}
+CFGDATA
+assert_ok "runtime list pipe output has no ANSI escapes" \
+ "$FBASH" -c "FLEET_CONFIG='$_TMP_CFG2' '$FLEET' runtime list > /tmp/fleet-no-ansi.out; ! LC_ALL=C grep -q \\$'\\033' /tmp/fleet-no-ansi.out"
+
+rm -f "$_TMP_CFG2"
+
echo ""
echo "Results: $PASS passed, $FAIL failed"
echo ""