Skip to content

feat: add ZCode as a BYOA engine via the zcode-acp-server ACP bridge - #247

Merged
yetone merged 4 commits into
yetone:mainfrom
lianxin255:feat/zcode-engine
Sep 10, 2026
Merged

feat: add ZCode as a BYOA engine via the zcode-acp-server ACP bridge#247
yetone merged 4 commits into
yetone:mainfrom
lianxin255:feat/zcode-engine

Conversation

@lianxin255

@lianxin255 lianxin255 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

What

Adds ZCode (the zcode CLI) as a BYOA engine. ZCode has no ACP surface of its own that the daemon can speak directly, so this drives it through the zcode-acp-server npm bridge, which wraps ZCode's headless app-server in the standard ACP agent methods (initialize / session/new / session/prompt over stdio) — the same wire protocol the Grok adapter already speaks.

The ACP bridge process

The bridge is a small npm package (zcode-acp-server, Apache-2.0) that presents a standard ACP agent: JSON-RPC over stdio (newline-delimited), no sockets, no listener, and nothing expected to be already running. The daemon spawns it per agent session and speaks client-side ACP to it over stdin/stdout; the bridge in turn spawns the actual zcode process and owns its lifecycle (its PATH lookup or ZCODE_BIN decides which CLI runs — Cumora only probes zcode to decide whether the engine is installed). The npm-published package is the source of truth: the daemon runs npx -y zcode-acp-server, so bridge fixes reach operators without a daemon release, and CUMORA_ZCODE_ACP_BIN pins an explicit copy for offline/version-locked setups. The bridge is a spawn-type dependency — it cannot be esbuild-bundled into the single-file daemon because its whole job is to be a separate process tree.

Design decisions

  • Compatibility tier. Cumora cannot impose a verified fail-closed host boundary on the bridge + app-server pair (the operator's zcode login and permission config decide what a turn may touch), so Zcode sits with Grok/Cursor/… behind CUMORA_BYOA_ALLOW_UNSANDBOXED=1 and is never auto-selected.
  • Wire-level connection + thin session. The handshake previously would have existed as four hand-rolled copies (persistent session, one-shot, two probes). A small AcpRpcConnection now owns spawn / JSON-RPC framing / initialize / session new|load / death, and ZcodeSession keeps only turn settlement, the lazy model pin, and the bridge-specific failure mapping — plain composition (no inheritance), since this codebase shares via helpers. The one-shot turn (run/classify/probe) and probeWake reuse the same connection; probeWake passes when a real handshake settles, so doctor exercises the production path.
  • Failure classification. All settle paths flow through classifyEngineResult. The bridge reports a missing backend session with the literal "Session is not active" (documented in its dispatch code), which the generic resume patterns don't match — a resumed session that lost its backend maps through an adapter-aware rule to resume-not-found, so runWithSessionRecovery gives zcode the same drop-and-fresh-retry as the other engines. Load-time stale resumes self-heal into a fresh session before any turn runs (second layer, not a substitute).
  • Model pin rides the bridge's session/set_config_option at the first prompt boundary (its session/new is lazy), retries on rejection, and stops once accepted.
  • No out-of-band standing-prompt channelcarriesStandingPrompt = false; the daemon inlines the scaffold per turn (the Cursor/OpenCode contract). Persona ships as AGENTS.md + .agents/skills/ (the directory the bridge's skill discovery actually scans).
  • Full registry wiring: detection, version probe, model catalog preset, byoa-zcode ledger/triage sources, auth hint, triage model, UI label/picker, en/zh-CN strings, docs. guard-engine-registry passes.

Verification

  • guard-engine-registry, guard-big-brain, tsc --noEmit, biome lint green. Full unit suite diffed against a pristine main checkout: identical failures (all pre-existing env-gated tests), zero new.
  • New contract tests (agents-computer-engine-zcode.test.ts) against a fake bridge injected through CUMORA_ZCODE_ACP_BIN: pin-once semantics, pin retry on rejection, stale-resume self-heal, the resume-not-found mapping, a bridge dying MID-TURN must resolve a failure (never reject), and probeWake/one-shot round trips.
  • Real-machine E2E against a local server with the npm bridge: daemon paired with --engine zcode, DM → wake (SSE) → local triage → persistent zcode session → model calls the cumora reply tool → reply lands in the conversation; second wake reuses the live session; llm_calls records byoa-zcode; --doctor: big ✓ small ✓ wake path ✓; daemon-restart resume via session/load verified.

Known limitation

The bridge does not put per-turn token usage in the session/prompt result, so llm_calls rows for zcode carry 0 input/output tokens (hop rows and model attribution are still correct). Bridge-side follow-up.

Docs updated in README.md and docs/BYOA.md.

@yetone

yetone commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Thanks — I read through this one. The shape is right for a new engine: it goes in through the adapter/registry seam rather than around it, and I specifically want to note that adding zcode to R4 in scripts/guard-big-brain.mjs tightens the guard (one more binary that can't be spawned outside the adapter) instead of carving out an exemption. That is the correct instinct and it is the thing I check first on every engine PR.

It cannot merge right now: it conflicts with main in server/src/agents/computer/registry.ts. main has moved a fair bit since you opened this — #235 added per-engine default model configuration and #231 (a702da0) reworked engine session storage and failure classification. Please rebase; the conflict itself is confined to registry.ts.

Two things to check while rebasing, both new since you branched:

  1. Engine failure classification. engine.ts now exports classifyEngineFailure / engineFailureOf and a structured EngineFailure { kind, message, diagnostic }. If the ACP bridge surfaces a missing-session condition, map it to resume-not-found so ZCode gets the same one-shot fresh-retry recovery as the other engines. If it cannot distinguish that case, leave it unknownunknown is deliberately never replayed, so that is the safe default, not a gap.
  2. Per-engine session storage. Session ids now live in ~/.cumora/sessions/<agent-id>/<engine>.session via EngineSessionStore, bound to one engine by construction. A new engine gets this for free as long as it goes through the runner rather than reading a session path itself.

Also worth a line in the PR description: what the ACP bridge process is, whether it is spawned by the daemon or expected to be already running, and what it listens on. For a new engine that is the part I most need to understand before merging, and it is not in the description today.

CI has not run on this yet because of the conflict — push the rebase and it will.

Drive the zcode CLI through the zcode-acp-server bridge's standard ACP
stdio surface (initialize / session/new / session/prompt), reusing the
GrokSession wire pattern for both a persistent per-agent session and the
one-shot run/classify/probe shape. The bridge resolves at spawn time:
CUMORA_ZCODE_ACP_BIN, then a require.resolve next to the daemon, then
npx -y zcode-acp-server.

The engine's model pin rides session/set_config_option once per session
(the bridge's session/new is lazy, so the pin lands at the first prompt
boundary) and falls back to the operator's zcode default when rejected.
There is no out-of-band standing-prompt channel, so carriesStandingPrompt
stays false and the daemon inlines the scaffold per turn.

Zcode stays a compatibility engine (CUMORA_BYOA_ALLOW_UNSANDBOXED=1
opt-in): Cumora cannot impose a verified fail-closed boundary on the
bridge + app-server pair, which runs on the operator's own zcode login
and permission configuration. Persona ships as AGENTS.md plus
.zcode/skills/; version probing uses zcode --version.
Add ZCode to the engine lists in the README BYOA bullet and throughout
BYOA.md: the compatibility table (persistent session via the bridge,
inlined standing prompt, AGENTS.md + .zcode/skills/), the wake-path
diagram, the per-agent home tree, and a paragraph covering the bridge
resolution chain and why Zcode stays behind the unsandboxed opt-in.
- Bridge resolution: the npm-published package is the source of truth —
  the daemon spawns 'npx -y zcode-acp-server' by default, so bridge
  fixes reach operators without a daemon release and no local checkout
  can shadow it. CUMORA_ZCODE_ACP_BIN remains the explicit pin for an
  offline or version-locked copy. Also resolve 'npx' (not 'npx.cmd')
  through resolveSpawn so the Windows PATHEXT lookup finds the shim.
- seedHome: zcode's skill discovery scans PROJECT skills from
  .agents/skills/ (shared with Antigravity), not .zcode/skills/ — the
  seeded persona now points at the directory the engine actually reads.
- ZcodeSession: stop re-sending the model pin on every wake once the
  bridge has accepted it; a rejected pin still retries on the next wake
  since the lazy session/new can reject before the backend session is
  live.
- Docs and the missing-engine hint updated to match.
Review follow-up for the v0.17.0 rebase (yetone#231, yetone#235):

- Failure classification: every zcode settle path flows through the
  shared classifyEngineResult vocabulary. The bridge surfaces a missing
  backend session with the literal "Session is not active" (documented
  in its own dispatch code), which the generic resume patterns don't
  match, so a resumed session that lost its backend maps through an
  adapter-aware rule to resume-not-found and runWithSessionRecovery
  gives zcode the same drop-and-fresh-retry the other engines get.
  Load-time stale resumes still self-heal into a fresh session before
  any turn runs.

- Restructure: the wire machinery (spawn, JSON-RPC framing, handshake,
  session new/load, death) collapses into one AcpRpcConnection, and
  ZcodeSession keeps only turn settlement, the lazy model pin, and the
  bridge-specific mapping — plain composition rather than inheritance,
  since the codebase shares via helpers. The one-shot turn and probeWake
  reuse the same connection instead of hand-rolling the handshake a
  third and fourth time.

- Contract tests behind a fake bridge injected through
  CUMORA_ZCODE_ACP_BIN: pin-once semantics, pin retry on rejection,
  stale-resume self-heal, resume-not-found mapping, a bridge dying
  MID-TURN must resolve a failure (never reject), and probeWake /
  one-shot round trips.
@lianxin255

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed read — all points addressed. Pushed the rebase plus a follow-up commit (1b4ae44):

  • Rebased on current main (v0.17.0; fix(byoa): isolate engine sessions and recover stale resumes #231 and feat(byoa): per-engine default model configuration on computers #235 included). The registry.ts conflict resolved per feat(byoa): per-engine default model configuration on computers #235's new shape — the fastModel passthrough is kept and zcode joins the default-model chain. Also confirmed the new EngineSessionStore comes free: the adapter goes through the runner and never touches a session path, and ~/.cumora/sessions/<agent>/zcode.session is written by the runner.

  • Failure classification: all zcode settle paths flow through classifyEngineResult now. The bridge surfaces a missing backend session with the literal "Session is not active" — its own dispatch code documents that phrasing, and the generic resume patterns don't match it — so the adapter maps that condition to resume-not-found when the turn ran on a resume, and runWithSessionRecovery gives zcode the same drop-and-fresh-retry as the other engines. Load-time stale resumes self-heal into a fresh session before any turn runs (a second layer, not a substitute). Where the adapter can't distinguish, the generic classifier's unknown stands, exactly as you described.

  • PR description: added a "The ACP bridge process" section — spawned by the daemon per session via npx -y zcode-acp-server, JSON-RPC over stdio, no listener, nothing pre-running; CUMORA_ZCODE_ACP_BIN pins an explicit copy.

While rebasing I also restructured the session internals: the handshake had ended up hand-rolled in four places (persistent session, one-shot, two probes), so the wire plumbing now lives in one small AcpRpcConnection and ZcodeSession keeps only turn settlement, the lazy model pin, and the mapping — plain composition, no inheritance, since this codebase shares via helpers. Added contract tests for the bridge surface behind a fake bridge injected through CUMORA_ZCODE_ACP_BIN (pin retry, stale-resume self-heal, the resume-not-found mapping, a bridge dying MID-TURN must resolve a failure rather than reject). Full suite diffed against pristine main: zero new failures.

CI should pick this up now that the conflict is gone.

@yetone

yetone commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Merging. This is a clean adapter — the ACP plumbing (AcpRpcConnection, session new/load, turn settlement) follows the shape of the existing engines rather than inventing a new one, and ZCODE_MISSING_SESSION_RE correctly maps a dead session to resume-not-found instead of failing the turn outright.

Two things I checked specifically, since they're the ones that bite silently:

  1. Registry wiring. guard:engine-registry passes, so zcode is in all of its lists including BYOA_SOURCES. Worth stating out loud why that matters: a half-wired engine doesn't error — normalizeByoaSource() maps anything unknown to byoa-claude, and the runs quietly bill to the wrong engine. This one is wired.
  2. Sandbox tier. SANDBOXED_ENGINE_IDS stays ['claude', 'codex'] — zcode is correctly not in it, so it lands in the compatibility tier behind CUMORA_BYOA_ALLOW_UNSANDBOXED=1. That's the right default for an engine we don't control the sandbox story for.

One note, not a blocker: resolveZcodeAcpSpawn falls back to npx -y zcode-acp-server, which resolves an unpinned version at run time. That's a different trust posture from the engines where the operator installs the binary themselves — -y means a compromised or simply broken publish executes without a prompt. CUMORA_ZCODE_ACP_BIN is the escape hatch and operators who care can set it, so this is fine as shipped; just something to revisit if zcode becomes a default rather than an opt-in.

Verified locally on top of current main (which moved under this PR while CI ran — four merges landed today): merges clean, and lint / typecheck / server:typecheck / all three guards / the unit suite are green on the merged tree.

https://claude.ai/code/session_01Tw4EygpE4o73TMLzyPFWEv

@yetone
yetone merged commit bce9acc into yetone:main Sep 10, 2026
7 checks passed
@yetone yetone mentioned this pull request Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants