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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ breaking changes may land in a minor release.
`in_tool`, and the TUI shows `· idle <age> · in <tool>` (#858).
- Add `[verify] command_timeout_min` (default 30): per-command verify timeout,
previously fixed at 30 minutes (#857).
- Add `adapter.codex-version` to `validate`: warn when Codex is older than 0.156 or the
profile's `launch_args` drop `--no-daemon` (#901).
- Journal `session-start-missing` when a session's CLI sends no SessionStart within
`[limits] session_start_grace_s` (default 120 s); a Codex sweep session with no hook
events names the shared daemon in its escalation text (#901).

### Changed

Expand Down Expand Up @@ -310,6 +315,10 @@ breaking changes may land in a minor release.
a failed pin falls back to the window and is crumbed `pane-pin-failed` (#948).
- Refuse to type a nudge into a pane in copy or clock mode, which swallowed it while
it counted as sent; the nudge is now recorded as not delivered (#949).
- Run Codex sessions with `--no-daemon` so tools and hooks see the session's
`BMAD_LOOP_*` environment, not a shared app-server daemon's; require Codex >= 0.156.
A Codex run paused across the upgrade prints the config-changed advisory once on
resume (#901).

### Security

Expand Down
7 changes: 4 additions & 3 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,11 +313,12 @@ bmad-loop drives a BMAD sprint unattended: a deterministic Python loop launches
- Bundled profiles: claude, codex, gemini, copilot, antigravity and opencode-http. The names `opencode` and `claude-code-tmux` resolve as aliases.
- A `.bmad-loop/profiles/*.toml` file whose `name` matches a bundled profile overrides it; a new `name` adds a CLI. Project files win over packaged and entry-point profiles.
- opencode runs as a headless `opencode serve` over HTTP/SSE. It needs no tmux window and registers no hooks. It speaks both OpenCode server APIs, 1.x and 2.x, picked per session from `opencode --version`; an unsupported major fails the session start with a clear error, and `validate` reports it as `adapter.opencode-version`.
- codex runs with `--no-daemon` (Codex ≥ 0.156), so shell tools and hooks run in the session's own process and see its `BMAD_LOOP_*` environment instead of a shared app-server daemon's. `validate` reports the version floor and a profile that drops the flag as `adapter.codex-version`; a session whose CLI never sends `SessionStart` is journaled as `session-start-missing` after `[limits] session_start_grace_s`.
- `bmad-loop probe-adapter <cli>` does a zero-launch scan by default. `--probe` opts in to one live, content-free turn. Every report is sanitized before it is printed.
- Antigravity (experimental) never reports token usage. Under worktree isolation, it trusts each unit worktree only when the project root is already trusted.
- **Config:** `[adapter] name`; `bmad-loop probe-adapter <cli> --probe`; `bmad-loop probe-adapter <cli> --workspace <path>`; `bmad-loop adapters`
- **Tested by:** `tests/test_profile.py`, `tests/test_probe.py`, `tests/test_adapter_registry.py`, `tests/test_workspace_trust.py`
- **Docs:** [Choosing which CLIs to drive](setup-guide.md#choosing-which-clis-to-drive), [Adapter authoring guide: finalizing a profile](adapter-authoring-guide.md#walkthrough-finalizing-a-profile), [CLI reference: probe-adapter](reference/cli.md#probe-adapter), [CLI reference: adapters](reference/cli.md#adapters)
- **Config:** `[adapter] name`; `[limits] session_start_grace_s`; `bmad-loop probe-adapter <cli> --probe`; `bmad-loop probe-adapter <cli> --workspace <path>`; `bmad-loop adapters`
- **Tested by:** `tests/test_profile.py`, `tests/test_probe.py`, `tests/test_adapter_registry.py`, `tests/test_workspace_trust.py`, `tests/test_codex_trust.py`, `tests/test_generic_tmux.py`, `tests/test_cli.py`
- **Docs:** [Choosing which CLIs to drive](setup-guide.md#choosing-which-clis-to-drive), [Codex shared app-server daemon](setup-guide.md#codex-shared-app-server-daemon), [Adapter authoring guide: finalizing a profile](adapter-authoring-guide.md#walkthrough-finalizing-a-profile), [CLI reference: probe-adapter](reference/cli.md#probe-adapter), [CLI reference: adapters](reference/cli.md#adapters)

### Per-stage adapters

Expand Down
8 changes: 8 additions & 0 deletions docs/adapter-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,14 @@ parent's stream. The generic adapter tells them apart by `session_id`
`probe-adapter --probe` shows the payload's field names; confirm the `session_id` and
`source` casing before relying on them.

The engine passes its `BMAD_LOOP_*` contract on the session window's environment, so only
processes descended from the launched CLI inherit it. A CLI that runs tool shells or hooks
in a long-lived server process it shares across sessions gives them the server's
environment instead: hooks relay nothing and skills cannot tell they run under bmad-loop.
Host that server per session. The `codex` profile passes `--no-daemon` in `launch_args`,
and the [opencode adapter](#worked-example-the-opencode-adapter) spawns one
`opencode serve` per session.

### `WorkspaceTrustSpec` (the `[workspace_trust]` table)

Some CLIs refuse to start in a workspace that is not listed in a home-level settings file,
Expand Down
1 change: 1 addition & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,7 @@ Every finding's `check` is one of these ids. Severity is `ok`, `warning` or `pro
| `adapter.httpx` | ok / problem | `httpx` is importable for an opencode-http profile |
| `adapter.launch-args-unservable` | warning | an opencode-http profile's `launch_args` cannot form a valid serve command |
| `adapter.opencode-version` | ok / warning | the OpenCode server API an opencode-http binary's `--version` reports is supported |
| `adapter.codex-version` | ok / warning | a Codex binary accepts `--no-daemon` (>= 0.156) and the profile's `launch_args` pass it |
| `adapter.hookless` | ok | the profile is hookless, so hook checks are skipped |
| `adapter.kind` | ok / problem | the profile's `adapter` kind is registered |
| `adapter.external` | warning | a third-party adapter entry point failed to load |
Expand Down
1 change: 1 addition & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Cycle/attempt caps, timeout & token budget.
| `teardown_grace_s` | integer | `20` | ≥ 0 | verified session teardown: poll the killed window up to this long, then force-kill its pane pids and re-kill the window · 0 = single unverified best-effort kill |
| `stop_without_result_nudges` | integer | `1` | ≥ 0 | |
| `dev_stall_grace_s` | integer | `600` | ≥ 0 | silence grace for a dev/review session, armed at launch and re-armed by transport activity or fresh Stop/idle evidence, before bounded nudges/stalled classification · also the transcript-idle notice threshold (journal session-idle/session-active, TUI idle age) · 0 = no launch timer (and no idle notice), but a result-less turn end still fails fast |
| `session_start_grace_s` | integer | `120` | ≥ 0 | seconds a session whose CLI maps SessionStart may run without one before a journal-only session-start-missing notice; 0 = off |
| `dev_stall_nudges` | integer | `2` | ≥ 0 | best-effort wake nudges for a silent dev/review session on grace expiry before it is called stalled; transport activity re-arms the grace and fresh Stop/idle evidence restores this budget · 0 = stall on grace expiry |
| `dev_stall_nudges_cap` | integer | `6` | ≥ 0 | total (never-restored) best-effort stall nudges for a dev/review session; bounds launch-time recovery and Stop/idle budget refills because an accepted nudge does not guarantee a wake · 0 = stall on first grace expiry |
| `workflow_stall_nudges_cap` | integer | `3` | ≥ 0 | total (never-restored) stall nudges for an injected plugin-workflow session before it is called stalled; bounds a session that finished its work but never wrote its completion marker · 0 = stall on first grace expiry |
Expand Down
10 changes: 9 additions & 1 deletion docs/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ them to whoever owns the machine:
dialogs.
- **codex** — run `codex` once in the project and accept **both** prompts: workspace trust,
then "Hooks need review → Trust all and continue" (untrusted hooks silently never fire).
Requires Codex ≥ 0.139.
Requires Codex ≥ 0.156.
- **gemini** — authenticate once (browser OAuth or `GEMINI_API_KEY`). Requires Gemini CLI
≥ 0.46.
- **copilot** — run `copilot` once in the project and authenticate (`gh` / a Copilot
Expand Down Expand Up @@ -458,6 +458,14 @@ Under `[scm] isolation = "worktree"`, every unit runs in a new worktree that Cod

Expect one pause per unit: each worktree path needs its own grant. The re-armed story restarts from its baseline. bmad-loop never writes Codex's trust state itself.

#### Codex shared app-server daemon

By default, Codex attaches to a shared background app-server daemon, which may have been started by an unrelated Codex session. Shell tools and hooks run inside that daemon, with the daemon's environment rather than the session window's, so they never see the session's `BMAD_LOOP_*` variables: a skill that checks `BMAD_LOOP_MODE` quits early, and the hook relay drops every event. The bundled `codex` profile therefore passes `--no-daemon` (Codex ≥ 0.156), which hosts the app-server inside the session's own client process.

- **Overlay profiles:** a `.bmad-loop/profiles/codex.toml` that sets `launch_args` must keep `--no-daemon` in them. It goes in `launch_args`, not `bypass_args`: a stage's `extra_args` replace `bypass_args`.
- **Check:** `bmad-loop validate` reports `adapter.codex-version`. It warns when the installed Codex is older than 0.156 (it rejects `--no-daemon`, so every session exits at launch), when the profile's `launch_args` lack `--no-daemon`, or when the version can't be read. It never fails validation.
- **Symptom:** when a CLI that maps `SessionStart` sends none within `[limits] session_start_grace_s` (default 120 s), the run journals one `session-start-missing` entry (`task_id`, `waited_s`, `grace_s`, `cli`, plus a `hint` naming the daemon for Codex). The notice is observation only and never ends the session. A sweep triage session on Codex that reports no hook events adds the same hint to its escalation text (`; hint: …`).

### Skill location

`claude` reads skills from `.claude/skills/`; `codex`, `gemini`, `copilot`, and `antigravity`
Expand Down
58 changes: 58 additions & 0 deletions src/bmad_loop/adapters/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,10 @@ def __init__(
# and belongs to every pane-driven session the same. Same knob, no new
# policy field; 0 disables the notice along with the stall timer.
self._idle_threshold_s = float(policy.limits.dev_stall_grace_s)
# Grace for the one-shot `session-start-missing` journal notice
# (`_note_session_start_missing`). Observation only, like the idle notice,
# so every adapter reads it, the plain one included; 0 disables it.
self._session_start_grace_s = float(policy.limits.session_start_grace_s)
# Wake-nudges to spend on grace expiry before stalling. 0 here is moot for
# the base adapter (grace 0 never opens the window); the dev adapter sets
# it from policy so an idle wait is re-invoked rather than killed outright.
Expand Down Expand Up @@ -1328,6 +1332,10 @@ def sample_frame() -> None:
# stalls or kills (#680 item 2 stays open), and `stall_deadline` is
# neither consulted nor touched.
idle = _IdleTracker()
# An admitted SessionStart arrived, and the one-shot `session-start-missing`
# notice was journaled; see `_note_session_start_missing`. Observes only.
saw_session_start = False
session_start_noted = False

# A transcript can grow for an initial user prompt or the loop's own
# wake nudge, neither of which proves model work. Latch model-side
Expand Down Expand Up @@ -1501,6 +1509,10 @@ def produced_work() -> bool:
# has named the transcript.
if transcript_path:
sample_transcript(transcript_path, now)
if not saw_session_start and not session_start_noted:
session_start_noted = self._note_session_start_missing(
handle, now - loop_started
)
self._write_heartbeat(
handle.task_id,
{
Expand Down Expand Up @@ -1949,6 +1961,7 @@ def produced_work() -> bool:
try_resolve()

if event.event == "SessionStart":
saw_session_start = True
continue
if event.event == "Notification" or event.event in PARKED_EVENTS:
# Parked-session signal (DW-348): latch it, never act on it here.
Expand Down Expand Up @@ -2290,6 +2303,51 @@ def flush_active() -> None:
else:
idle.open_since = idle.last_change_wall

def _note_session_start_missing(self, handle: SessionHandle, waited_s: float) -> bool:
"""Journal one `session-start-missing` (`task_id`, `waited_s`, `grace_s`,
`cli`) for a session whose profile maps SessionStart but that has run
`limits.session_start_grace_s` without one; True once written, so the
caller latches it.

A CLI whose hooks run without the window's BMAD_LOOP_* env (a Codex
session attached to a shared app-server daemon) fires its hooks into a
relay that exits silently, so the session would otherwise look healthy
until `session_timeout_min`. A `codex-hooks-json` profile adds `hint`,
naming that cause. Observation only: no deadline, nudge or verdict reads
it, and it fires on the plain adapter too, which arms no stall timer. No
journal, grace 0, a window that is not alive (or a liveness probe that
raised) or an unwritable journal writes nothing and leaves it unlatched,
and the session runs on."""
if (
self.journal is None
or "SessionStart" not in self.profile.hooks.events.values()
or self._session_start_grace_s <= 0
or waited_s < self._session_start_grace_s
):
return False
try:
if not self._window_alive(handle):
return False
except MultiplexerError:
return False
extra: dict[str, str] = {}
if self.profile.hooks.dialect == "codex-hooks-json":
from ..codex_trust import DAEMON_ENV_HINT

extra["hint"] = DAEMON_ENV_HINT
try:
self.journal.append(
"session-start-missing",
task_id=handle.task_id,
waited_s=int(waited_s),
grace_s=self._session_start_grace_s,
cli=self.profile.name,
**extra,
)
except OSError:
return False
return True

def _window_alive(self, handle: SessionHandle) -> bool:
return self.mux.window_alive(self.session_name, handle.native_id)

Expand Down
2 changes: 2 additions & 0 deletions src/bmad_loop/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@
"adapter.launch-args-unservable",
# opencode-http: the server API major `--version` reports (#847)
"adapter.opencode-version",
# codex: `--version` accepts --no-daemon and the profile passes it (#901)
"adapter.codex-version",
"adapter.kind",
"adapter.external",
"adapter.external-profile",
Expand Down
67 changes: 67 additions & 0 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,67 @@ def _check_opencode_version(report: ValidationReport, profile_name: str, resolve
)


def _check_codex_version(report: ValidationReport, profile: CLIProfile, resolved: str) -> None:
"""``adapter.codex-version``: whether ``resolved`` accepts ``--no-daemon``
(Codex >= :data:`codex_trust.CODEX_MIN_VERSION`) and whether ``profile``
passes it. Without it a session can attach to a shared app-server daemon that
runs tools and hooks with the daemon's env, so they never see BMAD_LOOP_*.
OK or a warning, never a problem: validate's rc is a compatibility contract.

Called only once ``binary_runs`` saw ``--version`` exit 0, like
:func:`_check_opencode_version`, so an unreadable second read warns here."""
from . import probe as probe_mod
from .codex_trust import CODEX_MIN_VERSION, NO_DAEMON_ARG, parse_codex_version

floor = ".".join(str(part) for part in CODEX_MIN_VERSION)
first_line = probe_mod.binary_version_text(resolved)
if first_line is None:
report.warn(
"adapter.codex-version",
f"{profile.name}: `--version` exited 0 once, but a second read failed, "
f"timed out, or printed nothing — sessions need Codex >= {floor} for "
f"{NO_DAEMON_ARG}",
{"profile": profile.name, "path": resolved, "output": None},
)
return
detail: dict[str, object] = {"profile": profile.name, "path": resolved, "output": first_line}
version = parse_codex_version(first_line)
if version is None:
report.warn(
"adapter.codex-version",
f"{profile.name}: `--version` printed {first_line[:200]!r}, which names no "
f"Codex version — sessions need Codex >= {floor} for {NO_DAEMON_ARG}",
detail,
)
return
found = ".".join(str(part) for part in version)
detail["version"] = found
if version < CODEX_MIN_VERSION:
detail["verdict"] = "below-floor"
report.warn(
"adapter.codex-version",
f"{profile.name}: codex {found} is older than {floor}: it rejects "
f"{NO_DAEMON_ARG} and every session exits at launch; upgrade Codex",
detail,
)
elif NO_DAEMON_ARG not in profile.launch_args:
detail["verdict"] = "no-daemon-missing"
report.warn(
"adapter.codex-version",
f"{profile.name}: launch_args lack {NO_DAEMON_ARG}, so sessions can attach "
"to a shared Codex app-server daemon whose tools and hooks never see the "
"BMAD_LOOP_* environment",
detail,
)
else:
detail["verdict"] = "ok"
report.ok(
"adapter.codex-version",
f"{profile.name}: codex {found} runs sessions with {NO_DAEMON_ARG}",
detail,
)


def _report_bmad_config(report: ValidationReport, paths: bmadconfig.ProjectPaths) -> None:
"""The `bmad-config` ok line, plus the v7 warnings. A v6 config reports exactly
as it always has; a v7 one names the era and the active initiative, warns when
Expand Down Expand Up @@ -1090,6 +1151,12 @@ def cmd_validate(args: argparse.Namespace) -> int:
resolved_binary = runnable.get(profile.binary)
if resolved_binary is not None:
_check_opencode_version(report, profile.name, resolved_binary)
if profile.hooks.dialect == "codex-hooks-json":
# The same packaged-provenance gate as the opencode read above; never
# a problem, for the same rc contract.
resolved_binary = runnable.get(profile.binary)
if resolved_binary is not None:
_check_codex_version(report, profile, resolved_binary)
if profile.hookless:
report.ok(
"adapter.hookless",
Expand Down
Loading
Loading