Skip to content

Add Agent Client Protocol (Zed) server — Phase 1 - #53

Draft
alpibrupa wants to merge 7 commits into
mainfrom
claude/acp-client-protocol-phase1
Draft

Add Agent Client Protocol (Zed) server — Phase 1#53
alpibrupa wants to merge 7 commits into
mainfrom
claude/acp-client-protocol-phase1

Conversation

@alpibrupa

Copy link
Copy Markdown
Contributor

Summary

lex-code already has an "ACP" server (src/server/acp.lex), but that's BeeAI's unrelated REST-based Agent Communication Protocol. This adds support for Zed's Agent Client Protocol: JSON-RPC 2.0 over stdin/stdout, NDJSON-framed — the standard Zed, JetBrains (IntelliJ/PyCharm/GoLand/WebStorm), Neovim, and Emacs use to launch and drive any coding agent as a subprocess. opencode is one of the other agents already on the shared ACP Registry.

New src/server/client_protocol.lex, Phase 1: initialize, session/new, session/prompt (streaming session/update notifications per step), session/close.

Deliberately not yet implemented — documented in the file header rather than silently missing:

  • session/request_permission — today's permission_spec is a static filter at agent construction, not an interactive round-trip; wiring this in changes when the check fires, not just adding a handler.
  • $/cancel_request — needs the turn running as a std.conc actor (the same pattern src/server/multi_agent.lex already uses for --multi) so the stdin-read loop stays free to receive a cancel mid-turn.
  • client-mediated fs/*, terminal/*, auth/login — all optional in ACP; lex-code's own read_file/write_file/bash tools and env-var provider keys already cover this ground directly.

session.lex gains run_turn_streaming_with_provider(session, input, provider_tag, on_step) alongside the existing run_turn_with_provider, so all four transports (TUI, A2A, BeeAI ACP, this) still share one turn-handling implementation per this repo's own AGENTS.md convention, rather than duplicating loop logic into the new transport file. Note: this is not token-level real-time streaming — lex-llm's run_loop_traced already runs the full loop to completion internally before returning its Iter, so on_step fires in a burst right after the blocking call returns, not interleaved with live generation.

Wire-format caveat (documented in the file and the README): the exact session/update field shapes are a best-effort reconstruction from available ACP v2 schema documentation, not a byte-for-byte verified trace against a reference SDK — the JSON-RPC 2.0 envelope and NDJSON framing themselves were verified directly against the protocol's own GitHub repo and multiple independent sources.

Verification

lex check <every tracked src/*.lex file + the new file>   # all ok
lex fmt --check src/                                        # all 77 files formatted
# manifesto demos (this repo's CI gate, no tests/ dir exists) — all pass/fail as expected

Isolated checks of every session/update variant's exact JSON shape (text chunk, tool-call begin/exec/result) via direct lex run calls.

Live end-to-end test: a real subprocess spawned exactly as an editor would, piped real NDJSON over stdin/stdout via a bash coprocess —

  • initialize
  • session/new ✓ (real ephemeral session + random sessionId)
  • unknown-method error (-32601) ✓
  • malformed-JSON error (-32700) ✓
  • session/prompt on an unknown sessionId (-32001) ✓
  • session/prompt on a real session with no provider key configured — graceful empty turn, no crash (matches lex-llm's existing provider-error-swallowing behavior, not new) ✓
  • session/close
  • clean exit after stdin closes ✓
  • empty stderr throughout — no non-protocol output on stdout ✓

Not independently verified: a real LLM call end-to-end (no provider credentials available in this environment) and interop against an actual ACP client (Zed) for the field-shape caveat above.


Generated by Claude Code

claude added 5 commits August 13, 2026 20:13
…ntion

lex-code was three weeks stale relative to the rest of the lex
ecosystem and lacked native support for the OpenCode Go plan
(https://opencode.ai/docs/zen) that lex-loom already uses.

OpenCode Go support (native + via the LiteLLM proxy, matching lex-loom):

- New `opencode_agent()` in all seven src/agents/*.lex files
  (build/explore/plan/refactor/review/spec_agent/test_agent), using
  lex-llm's existing `providers.opencode_go()` — no new package code
  needed there. Since the Go plan serves full-scale cloud models
  (DeepSeek, Qwen3, Kimi, GLM, MiniMax, MiMo), these get the same full
  tool budget as the other cloud providers (mistral/openai/google), not
  litellm/ollama's stripped-down local-model treatment.
- `tools.opencode_model()` (src/tools/index.lex) reads OPENCODE_MODEL,
  mirroring the existing litellm_model() pattern.
- Wired into src/server/session.lex's `pick_agent` and
  src/tui/main.lex's `select_provider_tag`/help text as a new
  `--opencode` flag.
- litellm/config.yaml + litellm/docker-compose.yml: lex-code had no
  bundled proxy config at all (the README referenced a
  "litellm_config.yaml at project root" that didn't exist). Copied
  from lex-loom's own litellm/ directory — same model list including
  the OpenCode Go entries (DeepSeek/Qwen3/Kimi/GLM/MiniMax/MiMo) — so
  both repos can point at one shared proxy instance, and `--litellm`
  now works out of the box.
- README: new "OpenCode Go plan" section, provider table row, and
  LiteLLM section pointing at the bundled config instead of a
  nonexistent file.

Housekeeping (bringing this repo to the same convention as
lex-agent/lex-soft/lex-loom/lex-memory):

- lex.toml: bump the `lex` version pin from 0.9.7 to 0.10.9 (CI itself
  already installs 0.10.7; the rest of the ecosystem is on 0.10.9),
  add `license = "EUPL-1.2"` and a description.
- LICENSE file (matches lex-lang/lex-os/lex-soft/lex-loom/lex-agent/
  lex-memory's EUPL-1.2 notice-and-pointer form).
- AGENTS.md + CLAUDE.md: this repo had neither, despite being the
  project that *builds* the coding-agent convention for everyone else.
  Documents the two-call-sites-per-provider pattern (agents/*.lex +
  session.lex's pick_agent), why there's no tests/ directory (CI gates
  on per-file lex check + the manifesto demos), and the local-model
  tool-budget curation.
- README: License section, v0.6 roadmap entry.

Verification:
- lex pkg install: resolves cleanly (12 packages).
- lex check on every tracked src/*.lex file (matches CI's own loop): all ok.
- lex fmt --check src/: all 76 files formatted.
- Manifesto demos (CI's actual regression gate, no tests/ dir exists):
  manifesto_parallel.lex passes, its negative twin is correctly
  rejected, manifesto_semantic_diff/run.sh validates.
- Live smoke test: two lex run invocations confirm build_agent.opencode_agent()
  constructs and session.pick_agent(Build, "opencode") dispatches to it
  correctly (script not part of this diff).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY
CI on the previous commit failed: "module \"src/memory\" not found in
package \"lex-agent\"". src/project_memory.lex imports lex-agent/src/memory,
which was deleted from lex-agent in favor of the new lex-memory package
(alpibrusl/lex-memory#1, alpibrusl/lex-agent#30) — a consumer this repo's
own audit at extraction time didn't know about, since lex-code wasn't yet
in scope. lex-code's lex.toml pins lex-agent with a loose git ref (no
branch/tag), so CI picked up the deletion the moment it landed on
lex-agent's main.

Fix: add lex-memory as a direct dependency and swap the import, same as
the migration already done for lex-soft/lex-loom. No behavior change —
project_memory.lex's own API (open/close/store_*/recall_*) is untouched;
only the underlying module moved.

Verification:
- lex pkg install: lex-memory resolves cleanly (13 packages).
- lex check on every tracked src/*.lex file: all ok.
- lex fmt --check src/: all 76 files formatted.
- Manifesto demos: unaffected, still pass.
- Live smoke test: two lex run invocations against a real file-backed
  SQLite db confirm store_convention (process 1) is correctly recalled
  via recall_for_prompt (process 2) through the new import.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY
Picks up lex-lang v0.10.10 (released today): the JIT step-counter fix
closing the --max-steps bypass (#465/#710), the new [approval] effect
for human-in-the-loop host boundaries (#737), and 'vcs' added to
lex test's default permissive effect policy (#736), among other
changes since v0.10.9/v0.10.7 (the two versions this repo had drifted
across between lex.toml and CI -- lex.toml said 0.10.9, ci.yml/
publish.yml still said 0.10.7).

Verification (against the real v0.10.10 release binary, not just the
version string):
- lex pkg install: resolves cleanly (13 packages).
- lex check on every tracked src/*.lex file: all ok.
- lex fmt --check src/: all 76 files formatted.
- Manifesto demos (this repo's actual CI regression gate, no tests/
  dir exists): manifesto_parallel.lex passes, its negative twin is
  still correctly rejected, manifesto_semantic_diff/run.sh validates.
No behavior differences observed from the 0.10.9 baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY
lex-code already has an "ACP" server (src/server/acp.lex), but that's
BeeAI's unrelated REST-based Agent Communication Protocol. This adds
support for Zed's Agent Client Protocol: JSON-RPC 2.0 over stdin/
stdout, NDJSON-framed, the standard Zed/JetBrains/Neovim/Emacs use to
launch and drive any coding agent as a subprocess (opencode is one of
the other agents already on the shared ACP Registry).

New src/server/client_protocol.lex, Phase 1: initialize, session/new,
session/prompt (streaming session/update notifications per step),
session/close. Deliberately not yet implemented, documented in the
file header rather than silently missing:
- session/request_permission — today's permission_spec is a static
  filter at agent construction, not an interactive round-trip; wiring
  this changes *when* the check fires, not just adding a handler.
- $/cancel_request — needs the turn running as a std.conc actor (the
  pattern src/server/multi_agent.lex already uses for --multi) so the
  stdin-read loop stays free to receive a cancel mid-turn.
- client-mediated fs/*, terminal/*, auth/login — optional in ACP;
  lex-code's own read_file/write_file/bash tools and env-var provider
  keys already cover this ground directly.

session.lex gains run_turn_streaming_with_provider(session, input,
provider_tag, on_step) alongside the existing run_turn_with_provider,
so all four transports (TUI, A2A, BeeAI ACP, this) still share one
turn-handling implementation per this repo's own AGENTS.md convention,
rather than duplicating loop logic into the new transport file. Note
in the code: this is NOT token-level real-time streaming — lex-llm's
run_loop_traced already runs the full loop to completion internally
before returning its Iter, so on_step fires in a burst right after the
blocking call returns, not interleaved with live generation.

WIRE-FORMAT CAVEAT (documented in the file and the README): the exact
session/update field shapes are a best-effort reconstruction from
available ACP v2 schema documentation, not a byte-for-byte verified
trace against a reference SDK — the JSON-RPC 2.0 envelope and NDJSON
framing themselves were verified directly against the protocol's own
GitHub repo and multiple independent sources.

Verification:
- lex check on every tracked src/*.lex file + the new file: all ok.
- lex fmt --check src/: all 77 files formatted.
- Manifesto demos (this repo's CI gate, no tests/ dir exists): all
  three still pass/fail as expected.
- Isolated checks of every session/update variant's exact JSON shape
  (text chunk, tool-call begin/exec/result) via direct lex run calls.
- Live end-to-end test: a real subprocess spawned exactly as an editor
  would, piped real NDJSON over stdin/stdout via a bash coprocess —
  initialize, session/new (real ephemeral session + random sessionId),
  unknown-method error (-32601), malformed-JSON error (-32700),
  session/prompt on an unknown sessionId (-32001), session/prompt on a
  real session with no provider key configured (graceful empty turn,
  no crash — matches lex-llm's existing provider-error-swallowing
  behavior, not new), session/close, and a clean exit after stdin
  closes. Empty stderr throughout — no non-protocol output on stdout.
  Not independently verified: a real LLM call end-to-end (no provider
  credentials available in this environment) and interop against an
  actual ACP client (Zed) for the field-shape caveat above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY
…hain

lex-lang v0.10.10 added a new [approval] effect (human-in-the-loop host
boundary, lex-lang#737). lex-llm's run_loop/run_loop_traced picked it
up immediately (their #41) -- both now require [..., approval]. Since
lex-code's lex.toml pins lex-llm with a loose git ref, this landed on
every open branch the moment it hit lex-llm's main, same mechanism as
the earlier lex-agent/memory break -- except this one is a legitimate
upstream feature, not a deletion, so the fix is pure propagation.

Adds `approval` to every function's effect row that transitively calls
ag.run_loop/run_loop_traced (directly, or via session.lex's
run_turn*/run_turn_streaming_with_provider): session.lex's three turn
functions, client_protocol.lex's handler chain (my own new file, this
session), bootstrap/{hello,run}.lex, server/{acp,api* no-op below,
multi_agent,web}.lex, and tui/main.lex's repl/run_once/multi_repl/
run_headless/main. Updated every --allow-effects example in the README
and in main.lex's own doc comment to match, so copy-pasted commands
still work at runtime.

*api.lex needed NO changes -- lex check flagged it in the original
sweep only because it transitively imports session.lex, which was
still broken; once session.lex was fixed, api.lex passed as-is. Same
false-positive risk applies to any file reached only by re-running
`lex check` on a single entry point -- verify against the CURRENT
state of each dependency before editing, not the first error message.

One file is NOT fixed here and can't be fixed from lex-code alone:
src/server/mcp_main.lex's make_handler is assigned into lex-agent's
Skill.handle, whose effect row is fixed by lex-agent's own Skill type
and does not include approval -- widening make_handler's row just
moves the failure to that assignment site (effect rows unify by exact
equality, not subtyping). This is a genuine incompatibility between
two of lex-code's dependencies (lex-agent's Skill type vs lex-llm's
now-approval-requiring run_loop), filed upstream as
alpibrusl/lex-agent#32 with a suggested fix (add approval to
Skill.handle's row). mcp_main.lex will stay red in CI until that
lands -- flagging here rather than masking it.

Verification (against the real v0.10.10 release binary):
- lex check on every tracked src/*.lex file + client_protocol.lex: all
  ok except mcp_main.lex (the filed, upstream-blocked issue above).
- lex fmt --check src/: all 77 files formatted.
- Manifesto demos: all three still pass/fail as expected.
- Live end-to-end re-test of the ACP server (real subprocess, real
  NDJSON over stdin/stdout via a bash coprocess) with the corrected
  --allow-effects list: initialize, session/new, session/close all
  round-trip correctly, clean exit, empty stderr.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY
alpibrupa added a commit to alpibrusl/lex-agent that referenced this pull request Aug 14, 2026
…ect (#33)

* Widen Skill.handle's fixed effect row to admit the new [approval] effect

Closes #32. lex-lang v0.10.10 added a new [approval] effect
(human-in-the-loop host boundary, lex-lang#737). lex-llm's
run_loop/run_loop_traced picked it up immediately -- their signatures
now require [..., approval]. Since Lex effect rows unify by exact
equality (not subtyping), no handler that calls run_loop could be
assigned to Skill.handle anymore:

  type Skill = { capability :: cap.Capability,
                 handle :: (msg.Message) -> [...no approval] HandlerOutcome }

Discovered via alpibrusl/lex-code#53, whose MCP handler calls
ag.run_loop directly inside a Skill.handle closure.

Widens Skill.handle's row to admit approval, then propagates through
every function in server.lex's dispatch chain that calls skill.handle
transitively (run_skill, dispatch_skill, handle_tasks_send,
handle_method, dispatch_request, and the SSE-subscribe mirror:
emit_skill_frames, run_skill_subscribe, build_subscribe_frames,
dispatch_subscribe_str, plus handle_tasks_get/handle_tasks_cancel) and
into mount.lex's rpc_route, this repo's only caller of that chain.
tests/test_server_trail.lex's echo_handler and its four dispatch_
request-calling test functions updated to match.

This is additive only -- no handler is now REQUIRED to use approval,
just permitted to. Existing handlers are unaffected (an unused
declared effect is structurally fine, not an error).

mount.lex's rpc_route is itself registered via lex-web's
router.route_effectful, whose handler-closure type was ALSO fixed
without approval -- fixing Skill.handle alone would have left
mount.lex newly broken (a regression from this fix, not a preexisting
gap). Companion PR: alpibrusl/lex-web#49, merge that first or
alongside this one.

Also bumped ci.yml's `lex test`/allow-effects list to include approval
-- CI passes an explicit list (not the default permissive test
policy), so 1 of 10 test files failed purely on runtime policy
("effect `approval` not in --allow-effects"), not any actual
assertion, once its handler's row grew to match Skill.handle.

Verification (against the real v0.10.10 release binary, and against
lex-web's fix checked out locally via a package-cache symlink, since
alpibrusl/lex-web#49 is unmerged):
- lex check --strict on every src/ and tests/ file: all ok.
- lex fmt --check src/ tests/: all 20 files formatted.
- lex test with CI's exact --allow-effects list: 10 passed, 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY

* chore: retrigger CI now that lex-web#49 is merged

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZagDSXr1qNv7dwkLrphHY

---------

Co-authored-by: Claude <noreply@anthropic.com>
claude added 2 commits August 14, 2026 18:03
Completes the widening this repo's turn-execution chain already
started: once lex-agent's Skill.handle (lex-agent#33), lex-web's
route_effectful handler type (lex-web#49), and lex-mcp's Skill
dispatch (lex-mcp#8) all required approval, the handlers this repo
assigns into those fixed types needed it too --
handle_chat (api.lex), make_handler/main (mcp_main.lex), and the
static-file route closure (web.lex).

Verified against the real v0.10.10 release binary with every upstream
fix (lex-agent, lex-web, lex-mcp, lex-agent-llm) resolved from main:

  lex check <every src/*.lex file individually>   # all ok
  lex fmt --check src/                             # 77 files formatted
  lex check examples/manifesto_parallel.lex        # ok
  lex check examples/manifesto_parallel_bad.lex    # correctly rejected
  bash examples/manifesto_semantic_diff/run.sh     # validated
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