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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ breaking changes may land in a minor release.

### Added

- Add a free-form `effort` key to `[adapter]` and every `[adapter.<stage>]` table,
inherited like `model`; `opencode-http` sends it as the per-prompt `variant` on
every turn, and `validate` warns (`policy.effort-unsupported`) when a tmux stage
sets it (#643).
- Journal a session's idle stretches (#680). The tmux adapter stats the live transcript
on the heartbeat cadence, stamps `transcript_idle_s` on `heartbeat.json`, and — with
the engine's journal attached (`CodingCLIAdapter.journal`) — writes one `session-idle`
Expand All @@ -27,6 +31,12 @@ breaking changes may land in a minor release.
dialog, a login, a dead-on-arrival window); `decide_dev` pauses ahead of the budget as an
environment fault does, `dev-decision` and `session-end` carry the flag, and re-arm
resets the attempt.
- Preserve inherited `model`, `effort` and `extra_args` when a stage names an alias
of the base client (`opencode` / `opencode-http`, `claude-code-tmux` / `claude`)
instead of treating it as a client switch.
- Key the `run --dry-run` launch preview on the adapter kind, not `profile.hookless`:
an `opencode-http` profile with a hook dialect shows the server/prompt_async line,
a hookless profile of another kind shows the argv line.

## [0.12.0] — 2026-09-20

Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,21 +462,30 @@ skill = "bmad-dev-auto" # the only supported value — the generic upstream d
[adapter]
name = "claude" # CLI profile: claude | codex | gemini | copilot | antigravity | opencode-http (alias: opencode) | custom
model = "" # empty = CLI default (opencode-http wants "provider/model")
effort = "" # reasoning effort, free-form (e.g. "high", "max"); empty = provider default.
# Sent by opencode-http as the per-prompt `variant`; the tmux CLIs have no
# channel for it and ignore it (`bmad-loop validate` warns)
cleanup_session_on_finish = true # kill the run's tmux session when it finishes (false keeps it for inspection)
# extra_args replaces the profile's default bypass flags when set:
# extra_args = ["--permission-mode", "bypassPermissions"]

# Optional per-stage overrides — run the review pass on a different CLI/model
# than the dev pass. Unset keys inherit from [adapter] when the stage runs the
# same client; switching client falls back to that profile's defaults (model
# and extra_args are client-specific).
# same client; switching client falls back to that profile's defaults (model,
# effort and extra_args are client-specific).
# [adapter.dev]
# model = "opus"
# [adapter.review]
# name = "codex"
# model = "gpt-5-codex"
# [adapter.triage] # sweep triage stage
# model = "opus"
# With an opencode-http base, effort tunes reasoning per stage (opencode-http
# only — a tmux CLI ignores it and `bmad-loop validate` warns). An unrecognized
# name is not rejected: the session silently runs at the provider default, so
# spell it exactly as the model's variant list names it.
# [adapter.review]
# effort = "max" # e.g. a deeper review pass than dev

[sweep]
auto = "never" # never | per-epic | run-end (auto sweeps never prompt)
Expand Down
1 change: 1 addition & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,7 @@ persisted artifacts.
- Supported, E2E-verified over HTTP/SSE (no tmux window): `opencode` (OpenCode ≥ 1.18, profile `opencode-http`, alias `opencode`) — one headless `opencode serve` per session, SSE `session.idle` completion with an HTTP poll fallback, per-session server password, token usage read back over the API. Hookless (`[hooks] dialect = "none"`, no hook registration). With no pane to replay, the run logs split three ways: a curated readable transcript in `logs/<task-id>.log` (agent/user prose, tool calls, slash commands, file edits, permission asks/replies, errors), the server's own stdout in `<task-id>.server.out`, and a structured SSE trace in `<task-id>.sse.jsonl`. Install the extra (`pip install 'bmad-loop[opencode]'`), auth once globally (`opencode auth login`), and set `model` as `provider/model`; the Unity plugin's window guards don't apply (there is no window).
- Experimental, `isolation = "none"` only: `antigravity` (Google's `agy` ≥ 1.1.3) — `-i` interactive launch, `Stop` turn-end hook (flat handler in `.agents/hooks.json`, no SessionStart/SessionEnd), `--dangerously-skip-permissions` for unattended runs; `usage_parser = "none"` permanently — agy's transcript exposes no usage data (tokens live only in an internal SQLite/protobuf store). `agy` gates each workspace on an exact-path `trustedWorkspaces` entry and blocks on an interactive trust dialog, which `--dangerously-skip-permissions` does not bypass — so worktree isolation hangs ([#169](https://github.com/bmad-code-org/bmad-loop/issues/169)). Verify against your `agy` build with `probe-adapter antigravity`.
- Per-stage CLI/model overrides: run dev on one CLI/model, review on another (`[adapter.dev]`, `[adapter.review]`, `[adapter.triage]`).
- Reasoning effort: a free-form `effort` string on `[adapter]` and every `[adapter.<stage>]` table (values are provider/model-specific — `high`, `max`, … — so nothing is validated against a catalog), inherited exactly like `model` (a stage that switches client falls back to `""` = provider default). Only the `opencode-http` adapter carries it: it is sent as the per-call `variant` on every `prompt_async` body the session issues (initial prompt and every nudge) and omitted entirely when empty, never through `OPENCODE_CONFIG_CONTENT` (whose only effort key, `agent.<name>.variant`, is inert unless that agent also pins a model). An unrecognized name is not rejected — OpenCode accepts the prompt and the session silently runs at the provider default — so spell it exactly as the model's variant list names it. The tmux CLIs have no channel for it and ignore it; `bmad-loop validate` reports `policy.effort-unsupported` (a warning, exit code unchanged) when a stage on that family sets it. The value never reaches argv, so `config_digest` is unaffected; `run --dry-run` shows it as `effort=<value>` beside the model on the hookless launch line.
- Add a CLI without touching Python: drop a TOML profile in `.bmad-loop/profiles/<name>.toml` (binary, prompt template, bypass flags, hook dialect, native→canonical event map). A CLI that needs its own adapter _class_ still needs Python — but not a core edit: the profile's `adapter` field names a kind resolved against the registry, which a co-installed package extends.
- `bmad-loop probe-adapter` collects + sanitizes the data needed to finalize/add a profile (hook payload shape, transcript location/format, token schema): a zero-launch scan by default, opt-in `--probe` for live capture. See the [adapter authoring guide](adapter-authoring-guide.md).

Expand Down
11 changes: 9 additions & 2 deletions docs/adapter-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,11 @@ Three frozen dataclasses cross the seam:

- **`SessionSpec`** (engine → adapter) — `task_id`, `role` (`"dev"` / `"review"` /
`"retro"`), `prompt`, `cwd`, `env`, `model` (empty = CLI default),
`timeout_s`.
`timeout_s`, and `effort` (empty = provider default; a free-form reasoning-effort
name resolved per stage from `[adapter] effort`). Only `opencode-http` carries
`effort` — as the per-prompt `variant` — and the generic tmux adapter ignores it,
because no profile key maps it onto a CLI flag; `bmad-loop validate` warns when a
stage on that family sets it. An out-of-tree adapter class may read it or not.
- **`SessionHandle`** (returned by `start_session`) — `task_id`, `native_id` (tmux
window id, HTTP session id, …), `launched_ns` (wall-clock ns just before launch;
the floor for hook events).
Expand Down Expand Up @@ -657,7 +661,10 @@ decisions worth stealing:
Permissions, the model, and a hermetic skills path are injected via the
`OPENCODE_CONFIG_CONTENT` env var (zero worktree pollution), and each server
gets its own `OPENCODE_SERVER_PASSWORD` so a foreign process on a recycled
port can never impersonate it.
port can never impersonate it. Reasoning effort (`SessionSpec.effort`) is the
one knob that does NOT go through the config: it is a per-call `variant` on
every `prompt_async` body instead, because the config has no top-level
`variant` and its `agent.<name>.variant` is inert unless that agent pins a model.
- **Map the transport onto the hook-signal semantics** instead of inventing new
ones: the SSE `session.idle` event ≙ the Stop hook, server-process death ≙
window death (`crashed`, landed artifact honored), and a poll fallback
Expand Down
4 changes: 3 additions & 1 deletion docs/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,9 @@ them to whoever owns the machine:
- **opencode** — install the HTTP client extra (`pip install 'bmad-loop[opencode]'`) and
authenticate once, **globally**, with `opencode auth login` (not per-project — there is no
workspace-trust dialog to answer). Requires OpenCode ≥ 1.18. Set the model as
`provider/model` (e.g. `[adapter] model = "anthropic/claude-haiku-4-5"`). No hooks are
`provider/model` (e.g. `[adapter] model = "anthropic/claude-haiku-4-5"`). A reasoning
`effort` (e.g. `[adapter.review] effort = "max"`) is sent as the per-prompt variant and is
opencode-only — the tmux CLIs ignore it. No hooks are
registered — the adapter drives a headless `opencode serve` over HTTP/SSE, so there is no
tmux window to attach to; watch a session via its `logs/<task-id>.log` — a curated
transcript of the agent's prose, tool calls, file edits and permission decisions — or the
Expand Down
3 changes: 2 additions & 1 deletion docs/tui-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -674,9 +674,10 @@ behavior.
| `review.trigger` | select | `recommended` | `recommended` (run only when the dev pass flags `followup_review_recommended`) / `always`; bounded by `limits.max_review_cycles` |
| `adapter.name` | text | `claude` | CLI profile: `claude` / `codex` / `gemini` / custom |
| `adapter.model` | text | (CLI default) | model override |
| `adapter.effort` | text | (provider default) | reasoning effort, free-form (`high`, `max`, …); sent by `opencode-http` as the per-prompt `variant`, ignored by the tmux CLIs (validate warns) |
| `adapter.extra_args` | override switch + args | profile defaults | see below |
| `adapter.cleanup_session_on_finish` | switch | on | kill the run's tmux session on finish; off keeps it |
| `adapter.dev` / `.review` / `.triage` | text ×2 + args | inherit | per-stage `name` / `model` / `extra_args` overrides |
| `adapter.dev` / `.review` / `.triage` | text ×3 + args | inherit | per-stage `name` / `model` / `effort` / `extra_args` overrides |
| `sweep.auto` | select | `never` | `never` / `per-epic` / `run-end` |
| `sweep.max_bundles` | int ≥ 1 | 5 | bundles per sweep; triage excess truncated |
| `sweep.max_triage_attempts` | int ≥ 1 | 2 | triage validation retries |
Expand Down
8 changes: 8 additions & 0 deletions src/bmad_loop/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ class SessionSpec:
# resumed run is protected too — always an absolute path by the time it lands
# here. Kept LAST alongside spec_snapshot so positional constructions stay valid.
expected_spec: str | None = None
# Reasoning effort (#643), free-form because the legal names are provider- and
# model-specific; "" = provider default. Resolved per stage by
# `AdapterPolicy.resolved()` with the same client-specific inheritance as
# `model`. Only the opencode-http adapter has a channel for it — it rides every
# `prompt_async` body as `variant` — and the tmux generic family ignores it
# (`bmad-loop validate` warns). Never reaches argv, so `config_digest` is
# untouched. Kept LAST so positional SessionSpec constructions stay valid.
effort: str = ""


@dataclass(frozen=True)
Expand Down
30 changes: 23 additions & 7 deletions src/bmad_loop/adapters/opencode_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,16 @@
``opencode.json``): a blanket permission allow (the bypass-flags
analogue), the hermetic-skills recipe above (project ``.claude/skills``
only — without it every session sees the operator's personal skills), and
the policy model when set. A per-session ``OPENCODE_SERVER_PASSWORD`` makes
the health poll self-discriminating against a foreign server on a reused
port and keeps other local processes from driving an allow-all server.
the policy model when set. Reasoning effort (``SessionSpec.effort``, #643)
deliberately does NOT ride the config: the config schema has no top-level
``variant``, and its only effort key (``agent.<name>.variant``) applies solely
when that agent table also pins its own ``model`` — inert otherwise, which is
the measured negative result in #643. It is sent instead as the per-call
``variant`` in every ``prompt_async`` body (``_prompt``), initial prompt and
nudges alike, and omitted entirely when empty. A per-session
``OPENCODE_SERVER_PASSWORD`` makes the health poll self-discriminating against
a foreign server on a reused port and keeps other local processes from
driving an allow-all server.
- **SSE ``session.idle`` ≙ the Stop hook**, filtered to this session's id —
child/subagent sessions share the stream and emit their own idles. SSE is
lossy upstream, so a silent or reconnecting stream degrades to an HTTP poll
Expand Down Expand Up @@ -370,6 +377,12 @@ class _ServerSession:
msg_roles: dict = field(default_factory=dict)
client: Any = None # control httpx.Client — main thread only
session_id: str = ""
# `SessionSpec.effort`, stashed once at session construction and sent as the
# per-call `variant` on EVERY prompt_async body this session issues (initial
# prompt and nudges — a nudge dropping back to the provider default mid-session
# would be silent drift). "" = omit the key, so the body is byte-identical to
# an effort-less session's.
variant: str = ""
events: queue.Queue = field(default_factory=queue.Queue)
sse_thread: threading.Thread | None = None
sse_stop: threading.Event = field(default_factory=threading.Event)
Expand Down Expand Up @@ -686,6 +699,7 @@ def start_session(self, spec: SessionSpec) -> SessionHandle:

launched_ns = time.time_ns()
sess = self._spawn_server(spec)
sess.variant = spec.effort
# Registered before the API handshake so the atexit sweep (and kill())
# covers a crash mid-setup; run()'s finally-kill only exists once
# start_session has returned a handle.
Expand Down Expand Up @@ -722,10 +736,12 @@ def _prompt(self, sess: _ServerSession, text: str) -> None:
starts no new turn, and consuming the floor for it would discard
still-valid completion evidence of the previous turn."""
sent_ms = _now_ms() # sampled before the POST: it precedes the new turn
resp = sess.client.post(
f"/session/{sess.session_id}/prompt_async",
json={"parts": [{"type": "text", "text": text}]},
)
body: dict[str, Any] = {"parts": [{"type": "text", "text": text}]}
# Reasoning effort is a per-call PromptInput key (#643); the key is
# omitted, not sent empty, so an effort-less session's body is unchanged.
if sess.variant:
body["variant"] = sess.variant
resp = sess.client.post(f"/session/{sess.session_id}/prompt_async", json=body)
if resp.status_code != 204:
raise OpencodeServerError(f"prompt_async failed: {resp.status_code} {resp.text[:200]}")
sess.floor_ms = max(sess.floor_ms, sent_ms)
Expand Down
7 changes: 5 additions & 2 deletions src/bmad_loop/adapters/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
names_tree_root,
names_win32_alias,
)
from ..policy import PROFILE_ALIASES
from .entrypoints import record_load_error

USAGE_PARSERS = {"claude-jsonl", "codex-rollout", "gemini-chat", "copilot-events", "none"}
Expand All @@ -63,8 +64,10 @@
CANONICAL_EVENTS = {"SessionStart", "Stop", "SessionEnd", "PreCompact"}
USER_PROFILES_REL = Path(".bmad-loop") / "profiles"

# legacy adapter names from older policy.toml files, plus friendly short names
ALIASES = {"claude-code-tmux": "claude", "opencode": "opencode-http"}
# Legacy adapter names from older policy.toml files, plus friendly short names.
# The table itself lives in `policy` (see `PROFILE_ALIASES` there for why) and
# is re-exported here under the name `get_profile` and `install` always used.
ALIASES = PROFILE_ALIASES


class ProfileError(Exception):
Expand Down
1 change: 1 addition & 0 deletions src/bmad_loop/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"bmad-config",
"policy",
"policy.model-qualified",
"policy.effort-unsupported",
"policy.isolation-repo-root",
"adapter.profile",
"adapter.binary",
Expand Down
Loading
Loading