yolop 0.12.1

Yolop — a terminal coding agent built on everruns-runtime
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
---
type: Product Specification
title: Extensions — capability servers over the yolop extension protocol
description: Defines the extensions — capability servers over the yolop extension protocol contract for Yolop.
---

# Extensions — capability servers over the yolop extension protocol

Status: **phases 1–3 implemented** in `src/extensions/`.
Phase 1: protocol core, transport-generic client, persistent process
manager, package discovery, `ExtensionCapability` adapter,
`[[capabilities]]` enablement, manifest clamp, never-defer wiring.
Phase 2: the always-on `extensions` capability with
`install`/`list`/`enable`/`disable`/`remove` tools, `extensions.lock`
content-hash pinning, install from git URL and local path, and the
`ext:<name>` enable/disable write path.
Phase 3: contributed MCP servers — a `yolop.mcpServers` manifest facet
(stdio/http), surfaced through `Capability::mcp_servers()` and merged into
scoped MCP config for enabled extensions, name-prefixed `<ext>__<server>`
and consumed by yolop's own MCP client (workspace `.mcp.json` still
overrides by name).
Phase 4: hook subscriptions (`yolop.hooks` — `pre_tool_use`/`post_tool_use`
with a tool-name glob, `timeout_ms`, and `on_error`) fired on the warm
server via `hook/fire`, and a `dynamic_prompt` facet recomputing the
system-prompt contribution per turn via `prompt/contribution` (falling back
to the static handshake prompt). Extension prompt contributions are collected
in the trailing environment-context segment so per-turn changes preserve the
stable system-prompt prefix for provider prompt caches. Pre-hooks can block;
per-hook timeout +
`on_error` (warn/block) bound the cost; the whole-bash-per-event
`user_hooks` precedent makes a warm-process RPC strictly cheaper.
Phase 5 (foundation): the **`yolop-yep`** crate (`crates/yolop-yep/`) — the
wire types (now the single source of truth, re-exported by the host) plus a
handler-based server SDK (`Server::new().tool().on_hook().dynamic_prompt()
.serve()`) so extension servers are authored in Rust without a hand-rolled
JSON-RPC loop. A reference `echo` example server built on it is driven by
yolop's own client in an integration test — the SDK's interop proof and the
foundation a future `yolop-extension-lsp` builds on.
Schema artifacts: `schema/yep/v1/meta.json` (method + capability-token
vocabulary) and `schema/yep/v1/schema.json` (Draft 2020-12 JSON Schema of every
request/result payload — keyed by method under `messages`, full types under
`$defs`) are both generated from the `yolop-yep` types by `cargo run -p
yolop-yep --features schema --bin schema-gen` (the mira pattern). `schema.json`
comes from `#[derive(schemars::JsonSchema)]` on the payload structs behind an
optional `schema` feature, so the published SDK stays serde-only for authors.
A `cargo test` drift guard (run under CI's `--all-features` coverage job) fails
if either committed file is stale, so the wire surface can't change without the
artifacts changing. A non-Rust author reads them to discover and validate the
wire format.
Conformance: `doctor_extension` (surfaced as `/extensions doctor`) spawns an
installed extension's server, runs the `initialize` handshake, and grades it
against the manifest — protocol-version compatibility, the D4 tool clamp
(a widened tool is a hard fail; an unserved manifest tool a warning), and
prompt-facet consistency — returning per-check pass/warn/fail. It is the
runtime dual of the CI schema/drift guards: authors validate a server before
shipping without booting a whole session.
Self-writing: `scaffold_extension` generates a ready-to-edit package — a
`plugin.json` declaring the requested facets (`tools`, `hooks`, `prompt`) plus
a self-contained capability server whose only author-editable seams are the
`handle_*` bodies. The skeleton is correct by construction: it installs via
`install_extension source=<dir>` and passes `doctor_extension` before any logic
is written, so authoring collapses to filling in handlers. The server is a
single executable under the package `bin/` (resolved via the `bin/`-on-PATH
rule the runtime already uses; the exec bit survives the install copy), so no
absolute paths leak into the manifest. The `python` and `typescript`
(dependency-free Node.js) templates are single-file and toolchain-free — the
fastest, most reliable path for yolop to author an extension for itself. The
`rust` template emits a `serde_json`-only crate (the compiled twin of the same
raw-protocol server); it carries a `build` step in the scaffold result because
the binary must be compiled into `bin/` before install, unlike the zero-build
templates. The
[`author-extension`](../../.agents/skills/author-extension/SKILL.md) skill drives
the loop (scaffold → implement → install → doctor → ask-to-enable); the
end-to-end acceptance — a self-authored `pre_tool_use` hook that blocks `git`,
spawned exactly as the runtime spawns it — is covered by
`scaffolded_extension_blocks_git_end_to_end`.
Status bar: an extension that declares `status` in its manifest (the D4 opt-in)
may push `status/changed` notifications carrying a short string the host shows
in its status bar (empty clears it). The wire type is `StatusChangedParams`; the
notification routes through an optional `StatusSink` created in `runtime.rs`
into a `SetExtensionStatus` `UiCommand`, then `App.extension_status`, then
`PresentationState`. It renders in the inline TUI (`status_contributions` /
`expanded_status_lines`) and the full-screen renderer (`app/fullscreen.rs`); it
is a no-op in `--print`/ACP, where the sink is `None` and the push only logs.
`scaffold_extension status=true` emits an `emit_status(text)` helper; the
acceptance — a self-authored char-counter that pushes to the sink — is covered
by `scaffolded_status_extension_pushes_to_the_sink`. ACP has no status surface
today, so extension status is intentionally TUI-only for now.
Skills: an extension that declares `skills` and ships a `skills/` directory
contributes its `SKILL.md` files read-only, discovered by the same
`ScopedSkillsCapability` scan as workspace/global/system skills. Each enabled,
declaring extension's `skills/` is seeded into a read-only in-memory mount at
`extension_skills_vfs(<name>)` (so an installed extension's skills can't be
mutated through the VFS) and added as a read-only `SkillScope` (label
`ext:<name>`) in `skills_config`. Loading follows the MCP-contribution rule
(enabled extensions only) and is host-side — no server or wire involvement.
`scaffold_extension skills=true` writes a starter `skills/<name>/SKILL.md`;
`extension_contributed_skill_is_visible_and_read_only` covers the mount.
Slash commands: an extension declares `commands` in its manifest; `ExtensionCapability`
implements the `Capability` `commands()`/`execute_command()` seams, so the
commands flow into `runtime.list_commands` → the palette automatically. Each is
registered namespaced (`<ext>:<name>`) so it can never shadow a built-in, and
invoking it sends `command/execute` (host→server) with `{name, arguments}`; the
server's `CommandExecuteResult { success, message }` becomes the `CommandResult`
shown to the user. `scaffold_extension commands=[…]` generates a
`handle_command` seam in all three templates. Covered by
`scaffolded_extension_serves_a_slash_command`.
ui/ask: an extension that declares `ui_ask` (the D4 opt-in) may send a `ui/ask`
*request* (server→host, the reverse-request direction) carrying
`UiAskParams { prompt, placeholder? }`; the host prompts the user and answers
with `UiAskResult { answer, cancelled }`. The client routes the request to an
`AskSink` (built in `runtime.rs`, gated on the TUI client) that pushes an
`AskRequest` onto a dedicated channel; `App` shows a single-line overlay
(`draw_ask_overlay`) that captures keys *before* the busy check so it works
mid-turn, then resolves the request's oneshot with the typed answer (or
`cancelled` on Esc). Only one prompt is live at a time; a request arriving while
another is pending is answered `cancelled`. Refused (`cancelled`) with no sink,
so `--print`/ACP never blocks on it. Covered by
`ui_ask_reverse_request_is_answered_by_the_sink`.
Config & secrets: an extension's `config_schema` is also its setup form. Two
yolop keywords extend JSON Schema at the property level: `secret: true` marks a
value as a credential, and `env: "NAME"` injects the value into the server's
environment under that name (so servers read env, not the wire). Non-secret
fields flow through `initialize.config` as before; a non-secret field that also
declares `env` is additionally injected as env, sourced from config. `secret`
fields are stored in the shared credential store (`connections.toml`, 0600,
redacted — `ExtensionSecrets`, keyed `ext:<name>`), **never** in `settings.toml`,
and reach the server as injected env only (`capability.rs` strips any `secret`
key from `initialize.config`). The `secret` marker decouples the concept from the
location: moving credentials to a keychain later changes only `secrets.rs`. Agent
leak-protection is threefold: entry is out-of-band via `set_extension_secret`
(and setup-on-enable), which prompts the *user* through the `ui/ask` surface —
the agent triggers it but supplies no value and is refused if it passes one; all
agent-facing reads (`list_extensions`) report a field's `set`/`unset` status,
never its value; and a redacting `Secret` newtype keeps values out of logs. On
`enable_extension`, every required field that is unset is prompted for (setup on
enable), dispatched by `ConfigField::kind`: `secret` → masked entry stored in the
credential store; an `enum` field → a **selector**; otherwise free text — both
persisted to capability config (`SettingsStore::set_capability_config`). The kind
enum is the extension point: new input kinds slot in without touching callers.
The prompts ride the one `ui/ask` reverse-request, now carrying `secret` (the
host masks input and shows `(saved)`, never the value — both TUI overlays honor
it via `ask_overlay_content`) and `options` (an arrow-key selector). Non-secret
config reaches SDK servers through `Server::on_config`, a handler the SDK invokes
once with `initialize.config` at handshake — so an author builds state (an
exporter, a client) from config without hand-parsing the wire. The `logfire`
extension dogfoods the whole path: `token` (secret → `LOGFIRE_TOKEN` env),
`endpoint` and `service_name` (plain config consumed via `on_config`). Covered by
`secret_config_field_is_injected_as_env`,
`set_extension_secret_refuses_agent_value_and_needs_a_prompt`,
`list_reports_secret_status_never_values`, `setup_on_enable_prompts_by_field_kind`,
and the SDK's `on_config_receives_initialize_config`.
Traces: an extension that declares `trace` in its manifest (the D4 opt-in)
receives the session's agentic event stream — each lifecycle event (`turn.*`,
`reason.*`, `act.*`, `tool.started/completed`, `llm.generation`) forwarded as a
fire-and-forget host→server `trace/event` notification, so it can export the run
to a tracing backend (Logfire, an OTLP collector). This is the observe-only dual
of `hook/fire`: `hook/fire` is a *request* that can block one tool call;
`trace/event` is a *notification* the host never awaits, so a slow or crashed
exporter can never stall the agent loop. The host taps the existing session
event broadcast (`JsonlEventEmitter::subscribe`, the same fan-out
`herdr.start_monitor` uses) and, for each enabled declaring extension, spawns one
forwarder task (`src/extensions/trace.rs`) filtered to the session; the facet is
eager-spawned so early events aren't missed, and high-frequency streaming deltas
are dropped (the paired `*.started`/`*.completed` events already bound the work).
The wire type is `TraceEventParams` (event type, ids, timestamp, and the event
`context`/`data` carried verbatim); the SDK exposes it as `Server::on_trace`
(a stateful `FnMut` so an exporter can pair spans across events). The reference
consumer is the `yolop-extension-logfire` crate — a capability server on the SDK
that folds the events into OpenTelemetry spans and POSTs OTLP/HTTP to Logfire,
configured by the same environment variables Logfire's onboarding checklist uses
(`LOGFIRE_TOKEN`, `LOGFIRE_ENDPOINT`, `OTEL_SERVICE_NAME`). Covered by
`trace_events_are_forwarded_to_a_declaring_server`; a non-declaring extension
gets no forwarder (`non_declaring_extension_has_no_trace_process`).
Live reload: `reload_extension name=<name>` restarts an already-enabled
extension's *server process* in place, so implementation edits take effect
mid-session without a yolop restart — the self-writing inner loop. Each
`ExtensionCapability` publishes its live `ExtensionProcess` into a shared
`LiveProcessRegistry` (a `Weak` map, so the registry never keeps a server alive
past its owning capability's cache); the management capability holds the same
registry and `reload()` upgrades the handle and drops the running child
(`kill_on_drop`), so the next tool/hook/command call respawns with a fresh
handshake. Crucially the *approved surface* is the session snapshot: the
manifest (tool names, schemas, prompt opt-in) was read at build and the harness
tool set is frozen there, so reload re-runs the binary within the same D4 grant
and can never widen it. A manifest change (a new tool, a changed schema) still
requires a restart — the enabled-capability set is fixed for the session
because `everruns-core` builds the harness once with no live-reconfigure seam.
Covered by `reload_respawns_the_server_with_edited_code`.
Hot-enable: `everruns-runtime` grew a live-reconfigure seam
(`InProcessRuntime::activate_capability`/`deactivate_capability` →
`CapabilityDelta`, EVE-795), so enabling a *new* extension no longer waits for
the next session. `enable_extension`/`disable_extension` still persist the
`ext:<name>` override to settings, and in the TUI they also push a
`SetExtensionActive` `UiCommand`; the `App` answers it by calling
`Session::activate_capability`/`deactivate_capability`, which mutate the
session-scoped capability overlay. The runtime reassembles prompt, tools, hooks,
commands, and contributed MCP servers from that overlay on the next reason/act
boundary, so the extension is live on the **next turn** — no restart. The
capability lands on the session overlay (distinct from the harness layer a
startup-enabled extension rides), so it can be live-deactivated too; disabling
one that was active from session start rides the harness layer and only settings
can drop it (next session). Refused with no live session (`--print`/ACP), where
enable/disable persist for the next run. Covered by
`enable_disable_emit_live_activation_when_wired`.
Later — the full `yolop-extension-lsp` control-plane extraction (gated on
`evals/lsp_integration` parity to retire the built-in),
`workspace/changed`,
providers — remain design-of-record below.
Toolchain-free crates.io install is now wired: `install_extension
source="crates.io:yolop-extension-<name>[@ver]"` (or the bare-`<name>`
shorthand) resolves the version through the crates.io **sparse index**
(`index.crates.io`), downloads the `.crate` from the static CDN, verifies its
SHA-256 against the index `cksum`, and unpacks the gzip'd tar — no cargo/rustc.
A published crate ships *source*, so the manifest's `capabilityServer.command`
names a binary the user has on `PATH` (or in the package `bin/`); yolop does
not compile it (`cargo install` remains an optional author-side path, not a
yolop dependency). Still follow-ups: full JSON-Schema config validation and
`config/changed` restart. Phase-1 deltas from the original sketch: tool *definitions*
(description, schema, policy) live in the manifest, and the handshake's
`tools` list carries only the served names it narrows to — keeping every
contribution inspectable without executing the binary; `tool/update`
progress surfaces through the tracing layer until the TUI grows a live
seam.

## Why

Yolop's unit of functionality is the everruns **capability**: one `Capability`
implementation contributes tools, system-prompt text, config schema, hooks,
MCP servers, skills, and commands through well-defined trait seams, and the
harness is an ordered, user-editable list of capability refs
(`[[capabilities]]` in `settings.toml`). The contract is right; the delivery
is wrong: every capability is compiled in. The `lsp` capability (#236) is the
canonical example — ~3.4k lines of Rust that had to land in this repo, ride
the release train, and grow everyone's binary, including the majority who
keep it off.

An **extension** delivers a capability as an installed artifact, no
recompilation. Near-term: tools + prompt + config + hooks (the LSP class).
Later: LLM providers, UI features, everruns plugins.

**Scoping decision.** Declarative contributions (prompt text, skills,
commands, static MCP server registrations) are arriving upstream as
**everruns plugins**: a plugin directory (Claude Code–compatible
`plugin.json` dialect) compiles to a `DeclarativeCapabilityDefinition`
carried *inside* the `plugin:{name}` capability config and hydrated at
collection time. Yolop inherits that layer by staying in lockstep; this spec
does not redesign it. What yolop must own is the tier upstream does not
have: **executable, stateful, capability-level logic in a subprocess** — the
*capability server* — and the protocol between yolop and it: the **yolop
extension protocol (YEP)**.

## Prior art (compressed)

Full survey history in the git log of this file. What bears on the protocol
tier:

- Hosts without a dynamic language runtime (goose, Crush, Claude Code, Zed
  for anything heavy) all converged on **subprocess + protocol**; in-process
  breadth (pi's ~35-event TypeScript API, OpenCode's JS middleware) is a
  property of having a scripting runtime, not a design yolop can copy.
- **goose** proves "extension = MCP server" works but also shows its ceiling:
  tools/resources/prompts only — no prompt shaping, no loop hooks, no config
  schema, no streaming tool output. That ceiling is exactly the gap between
  MCP and the `Capability` trait.
- **Zed** shows the strongest versioning story (per-version WIT snapshots)
  and the control-plane/data-plane split: extension logic decides, host- or
  extension-spawned subprocesses do the heavy work.
- **VS Code**'s durable lessons: static manifest so contributions are
  inspectable without executing code; keep extension code off the
  latency-critical path.
- **pi**'s anti-MCP argument is really about context economics (tool schemas
  burning 5–10% of the window). The antidote is declared tool policy, not a
  particular transport.
- **mira** (sibling project; its eval protocol is ACP-inspired) is the
  in-house existence proof for building a protocol of exactly this shape:
  stdio ndjson JSON-RPC dialect, field-classified messages, capability
  tokens + `capability_params`, `MAJOR.MINOR` with hard forward-compat
  rules, schema generated from Rust types with CI drift guards, native
  zero-dependency SDKs generated from the schema, and staged unstable
  additions. YEP adopts these conventions wholesale (see "Protocol
  construction") rather than rediscovering them.

## Design decisions

### D1 — YEP is its own protocol; MCP is a *contribution*, not the base

A capability server speaks **YEP**: yolop-owned methods and lifecycle,
JSON-RPC 2.0 as the message envelope (the same envelope LSP, DAP, and MCP
use — it is framing, not semantics) over stdio, newline-delimited. The
protocol mirrors the `Capability` trait directly instead of bending the
contract to fit another protocol's shape.

Why not an MCP superset (the previous revision of this spec recommended one;
recorded here as rejected):

- **The contract should evolve with the trait, not with MCP.** Tool policy,
  hooks, prompt contributions, config push, streaming tool output — none of
  these exist in MCP; a superset means forever expressing yolop semantics
  through someone else's extension escape hatch and tracking their spec's
  churn underneath.
- **Tool calls need a richer lifecycle than MCP offers**: streamed
  progress/partial output while a tool runs (yolop's TUI already renders
  streaming `bash` output; extension tools must not regress to
  spinner-until-done), cancellation, and structured result payloads aligned
  with `ToolExecutionResult` — native YEP messages rather than bolted-on
  progress notifications.
- **The cross-host argument inverts.** The superset's main selling point was
  "the same binary also works in goose/Claude Code." Composition achieves
  that better: an extension that wants cross-host reach *provides an MCP
  server* (next paragraph) and keeps the yolop-specific surface in YEP.

**MCP as a contribution.** An extension can announce, in its handshake:
*"I provide these MCP servers"* — each entry a name plus transport (a stdio
command, or an HTTP URL, typically `localhost` served by the extension
process itself). Yolop merges them into scoped MCP config and consumes them
through its **existing MCP client path**, exactly as if they came from
`.mcp.json`. This is not a new seam: it is the wire form of
`Capability::mcp_servers_with_config()`, which compiled-in capabilities
already use. Division of labor:

- **Native YEP tools** — streaming, stateful, policy-rich, session-scoped;
  the LSP class.
- **Contributed MCP servers** — wrapping/bundling the existing MCP
  ecosystem: an extension can manage credentials, config, or lifecycle for a
  third-party MCP server and hand yolop the endpoint. Stateful contributed
  servers should prefer an extension-hosted HTTP endpoint (the extension
  owns the process, so state survives) since yolop's stdio MCP transport is
  spawn-per-call today.

The cost of a bespoke protocol is the missing SDK ecosystem. The mitigation
is not hand-waving — it is the pattern [mira](https://github.com/everruns/mira)
already ships for its eval protocol (itself an ACP-inspired stdio dialect;
YEP joins the same family and follows `mira/docs/protocol.md` as its style
template): a schema-first contract with generated, native SDKs and a
conformance harness. See "Protocol construction" below.

### D2 — Persistent, session-scoped processes

A capability server is spawned once per session and lives for it —
persistence is intrinsic to YEP, not an option. Spawn is eager at session
start when the extension contributes prompt text or hooks (needed before the
first turn), lazy on first tool call otherwise. `kill_on_drop`; crash fails
pending requests with a clear message and the next call respawns with
backoff — the policy `lsp/manager.rs` already implements for language
servers. Graceful path: `shutdown` request, then `exit` notification,
SIGKILL after a grace window.

The worktree caveat is first-class: yolop can repoint the active workspace
mid-session (`WorkspaceHost`), so the workspace root arrives in `initialize`
**and** changes arrive as `workspace/changed` notifications; servers that
cache roots (the LSP class) must handle it.

### D3 — Every trait seam gets one of three treatments

The core of the design. The `Capability` trait's seams split by how hot
their call path is:

| Treatment | Seams | Mechanism |
|---|---|---|
| **Static** — declared, no RPC after startup | id/name/description/category, `config_schema`, static system prompt, tool definitions + policy (naming, never-defer, approval/risk hints, narration templates), **provided MCP servers**, commands, hook *subscriptions*, dependencies, features | Package manifest + `initialize` handshake; a generic yolop-side adapter answers the trait from this cache |
| **RPC** — cold path, bounded, fallible | tool execution (streamed), dynamic system prompt (≤1/turn, timeout + last-known-good fallback), hook firings, user questions, config push, workspace change | YEP requests/notifications over the persistent connection, each with a declared timeout and error policy |
| **Excluded** from the wire (v1) | `facts`, `message_filter_provider`, `model_view_provider`, `tool_definition_hooks` | Per-request hot loop, documented "cheap and side-effect free" — a cross-process round-trip there is hostile to latency and prompt-cache stability. If real demand appears, this is the future WASM tier's job, not a protocol extension |

Hooks over RPC deserve the explicit precedent: `user_hooks` today spawns a
**whole bash process per event**. A request to an already-warm process is
strictly cheaper than the mechanism yolop already ships — but bounded
anyway: subscriptions are static (event + tool-name matcher + `timeout_ms` +
`on_error: warn|block`), a match-all matcher must be spelled `"*"` and is
called out at approval time, and per-extension subscription count is capped.

### D4 — The manifest is the approval boundary; the handshake may only narrow it

Static facets live in **both** the package manifest and the handshake, with
a strict relationship:

- The **manifest** (`plugin.json` + `yolop` facet block) is what the user
  approves at install time — it must be inspectable *without executing the
  binary* (the VS Code lesson). It declares the server command and the upper
  bound of everything: tool names, never-defer list, hook subscriptions,
  provided MCP servers, prompt size, config schema.
- The **handshake** is the runtime truth, **clamped by the manifest**: a
  server may declare fewer tools, hooks, or MCP servers than approved
  (feature-gated builds), but anything not in the approved manifest is
  refused and logged. A server that tries to widen its grant after
  installation is the exact attack the clamp exists for. Provided MCP
  servers are clamped by *name and transport shape* — an approved stdio
  command cannot silently become a remote URL.

The lockfile's content hash makes the boundary durable: what was approved
is pinned, and an `update` whose manifest widens the grant re-asks with a
contribution diff before anything runs.

### D5 — Config rides the existing capability machinery

An enabled extension is one harness entry — `[[capabilities]]
ref = "ext:<name>"` — so enable/disable/configure/validate ride the existing
overrides, catalog, and `set_config` tools unchanged. The declared
`config_schema` plugs into `CapabilityCatalog` validation exactly like a
built-in's.

Delivery: validated config is passed in `initialize` params, and pushed on
change as `config/changed`; the server answers `ok` or `restart-required`
(yolop restarts it — the same "rebuild manager on config change" semantics
`LspCapability` has today). Secrets stay in env (`${VAR}` expansion in the
server spec, as `.mcp.json` does now); config is for structure, not
credentials.

### D6 — Tool policy is part of the tool definition

The LSP eval produced two hard facts: tools deferred behind `tool_search`
stubs get ~zero adoption, and prompt guidance names exact tool names. In a
yolop-owned protocol this is native — each declared tool carries:

- its **real name** (`lsp_definition` — no forced prefix; registered only if
  it collides with nothing built-in or already installed; on collision, the
  extension-qualified form with a startup warning),
- **`never_defer`** (schema always loaded, budgeted: ≤8 per extension so one
  package cannot blow the context window; everything else defers behind
  `tool_search` — the answer to pi's context-cost critique),
- **approval/risk hints** consumed by the approval capability, and an
  optional **narration template** (static templates beat narration RPC),
- **`streaming`** — whether the tool emits `tool/update` progress.

## The protocol surface

### Wire model (mira conventions, adopted)

YEP's wire model is lifted from the mira eval protocol — the ACP-inspired
stdio dialect this codebase's sibling already specifies, generates, and
tests — rather than invented fresh:

- **Transport & framing.** Child-process stdio; newline-delimited JSON, one
  UTF-8 object per line, blank lines ignored. `stdout` carries **only**
  protocol JSON; `stderr` is free for the server's logs and is never parsed.
  EOF on stdin signals clean exit (then `kill_on_drop` after grace).
- **Field-based message classification.** A line bearing `method` is a
  request (with `id`) or notification (without); only a `method`-less line
  is a response. Classification is by fields, not by direction or pipe.
- **Bidirectional from day one, with mira's reserved-seam invariants made
  live**: independent `id` spaces per direction (a response matches pending
  requests *on the same side* only), and each direction's optional methods
  are capability-negotiated — a peer never emits a method the other side
  did not advertise. YEP needs the reverse direction immediately
  (`ui/ask`, `status/changed`, `log`), so these invariants are v1 behavior,
  not a reservation.
- **Correlated notifications.** A notification cannot carry the envelope
  `id` (that would classify it as a response), so streamed `tool/update`
  events correlate to their in-flight call via `request_id` in the payload —
  the same demultiplexing key mira uses, which is what lets many tool calls
  (and their update streams) multiplex over the single pipe.
- **Errors are JSON-RPC-shaped and defaulted**: `code` (JSON-RPC
  conventions, `0` = unclassified), required `message`, optional
  `retryable` hint and structured `data`. A bare `{"message": …}` parses.
- **Cancellation by request id.** `cancel {id}` is a generic method
  addressing any in-flight request (mira semantics): best-effort, `false`
  is benign, and the cancelled call itself resolves promptly with error
  `cancelled` instead of hanging. This is the lever for user interrupts and
  turn aborts.
- **Multiplexing.** Yolop may keep many requests in flight; responses
  correlate by `id` and arrive in any order. Parallel tool calls in one
  turn ride this directly.

### Versioning (mira rules, verbatim)

`MAJOR.MINOR`, negotiated at `initialize`; v1 ships as `1.0`. Majors must
match — yolop refuses a mismatched server with a startup warning, never a
crash. Minors are additive. Forward compatibility is a hard requirement on
both sides: **ignore unknown fields** (no deny-unknown-fields on the wire),
**default missing fields**, and **feature-detect via capability tokens, not
version sniffing**. The handshake's contribution facets are capability
tokens with structured `capability_params` (open vocabulary, carried
verbatim when unrecognized): `tools`, `streaming`, `hooks`, `prompt`,
`dynamic_prompt`, `mcp_servers`, `commands`, `ui_ask`, `trace`, `cancel`,
`provider` (future). A server advertising only `tools` is fully conforming.

### Methods

| Direction | Method | Kind | Purpose |
|---|---|---|---|
| →server | `initialize` | req | protocol version, session id, workspace root, locale, validated config, host feature set |
| ←server | (result) || identity, contributions (prompt, tools, hooks, MCP servers, commands), clamped by manifest |
| →server | `initialized` | ntf | handshake complete |
| →server | `tool/call` | req | `{tool_call_id, name, args}`; response is the final `ToolExecutionResult`-shaped payload; the host uses the manifest tool name as stable activity narration |
| ←server | `tool/update` | ntf | streamed progress/partial output; correlates via `request_id` in the payload |
| →server | `cancel` | req | `{id}` — abort any in-flight request (mira semantics: best-effort, aborted call resolves with error `cancelled`) |
| →server | `prompt/contribution` | req | dynamic system prompt (only if declared `dynamic`); timeout + last-known-good |
| →server | `hook/fire` | req | `{event, toolName, payload}` → decision per subscription (`allow/block/mutate`) |
| →server | `trace/event` | ntf | forward one agentic-lifecycle event (turn/reason/act/tool/llm) to a `trace`-declaring extension; observe-only, never awaited |
| →server | `config/changed` | req | new validated config → `ok` \| `restart-required` |
| →server | `workspace/changed` | ntf | active worktree/root repointed |
| ←server | `ui/ask` | req | user question/form — bridged to the `user_ask` capability |
| ←server | `status/changed` | ntf | capability status (e.g. `degraded: rust-analyzer not found`) surfaced in `/extensions list` |
| ←server | `log` | ntf | structured logs → yolop's tracing layer (`RUST_LOG` honored) |
| →server | `shutdown` / `exit` | req/ntf | graceful stop before `kill_on_drop` |

```
yolop                                   capability server (ext:lsp)
  |-- spawn (session start; prompt facet present)
  |-- initialize {protocol_version:"1.0", session_id, workspace_root,
  |               config:{...},
  |               capabilities:["streaming","hooks","ui_ask","cancel"]}
  |<- result {protocol_version:"1.0", name:"lsp",
  |           capabilities:["tools","streaming","prompt"],
  |           capability_params:{
  |             prompt:{static:"<directive text>"},
  |             tools:[{name:"lsp_definition"}, ...x7]}}
  |             # served names only — definitions (description/schema/
  |             # policy) live in the manifest; clamped vs manifest (D4)
  |-- initialized
  |   ... agent turn ...
  |-- {id:1} tool/call {tool_call_id:"t1", name:"lsp_definition", args:{...}}
  |<- {id:1} (result)                   # server keeps rust-analyzer warm
  |-- {id:2} tool/call {tool_call_id:"t2", name:"lsp_rename", args:{...}}
  |<- tool/update {request_id:2, output:"3/14 files patched"}
  |<- {id:2} (result)
  |-- {id:3} cancel {id:2}              # had it still been running:
  |<- {id:3} {cancelled:true}           #   ...and id:2 resolves error "cancelled"
  |   ... user edits settings ...
  |-- config/changed {config}          <- "restart-required" -> respawn
  |   ... session end ...
  |-- (close stdin) / shutdown          # EOF => exit; SIGKILL after grace
```

### Protocol construction (the mira pattern)

How the contract is defined, published, and kept honest — adopted wholesale
from mira, which has already proven each piece for a protocol of this exact
shape:

- **Rust types are the source of truth; the schema is generated.** Wire
  types live in the `yolop-yep` crate; a schema-gen binary emits
  `schema/yep/v1/schema.json` (JSON Schema 2020-12, every payload under
  `$defs`) and `meta.json` (protocol version, method list, capability
  tokens, event vocabularies). The directory is versioned by protocol
  **major**. CI runs the generator with `--check` (and the drift tests under
  `--all-features`) so a wire change cannot merge without a matching schema
  update.
  *Implemented shape:* rather than a root `anyOf` over the three envelopes
  (the envelope is field-classified — see `classify_line` — and its
  direction vocabulary already lives in `meta.json`), `schema.json` keys
  payloads by **method** under a `messages` map (`{ params, result }` refs),
  with the shared `error` and `capability_params` shapes at the top level and
  all types under `$defs`. A conformance corpus (`schema/yep/v1/
  conformance/`) remains a follow-up.
- **SDKs are native, zero-dependency libraries, never FFI bindings.** The
  protocol is the seam by design; bindings would re-couple what the wire
  decouples. Each SDK ships a small codegen with its own `--check` drift
  mode that generates wire types from `schema.json` and protocol metadata
  from `meta.json` — the version string and method/capability vocabulary
  are generated, not hardcoded — plus a hand-written ergonomic layer: a
  `serve()` stdio loop and tool/hook registration helpers. Rust
  (`yolop-extension`, dogfooded by `yolop-extension-lsp`) first; TypeScript and
  Python when demand shows, following `mira/knowledge/specs/sdks.md` including its
  drift-guard table (handled-methods ⊇ meta methods, advertised
  capabilities ⊆ meta tokens, emitted messages validate against schema).
- **Unstable staging.** New wire *structure* develops behind a
  `yep-unstable` cargo feature; the schema generator builds without it, so
  the committed schema describes only the stable protocol and an addition
  reaches the artifact (and a minor bump) only when promoted. Open
  vocabularies (capability tokens, `capability_params`, metadata maps)
  extend without any bump at all.
- **Conformance harness in the product**: `/extensions doctor <cmd>` drives
  a server through handshake, tool call, streaming, cancellation, and
  config push, validating every message against the committed schema — the
  runtime dual of the CI guards, pointed at third-party servers.

### The adapter

The yolop side is one generic `ExtensionCapability` adapter implementing
`Capability`: static answers from the clamped handshake cache; `tools()`
manufactured from declared tools (calls proxied as `tool/call`, updates
streamed to the TUI); `mcp_servers_with_config()` returns the contributed
endpoints for the runtime's scoped-MCP merge; `pre/post_tool_exec_hooks()`
manufactured from subscriptions; `system_prompt_contribution()` from the
static text or the RPC. Registered per installed+enabled extension at
startup — `CapabilityRegistry::register` takes `Arc<dyn Capability>` at
runtime, so registration needs no upstream change.

## Packaging, install, trust

The package is the everruns plugin directory — the declarative layer arrives
upstream; yolop adds one facet:

```json
{
  "name": "lsp",
  "version": "0.1.0",
  "description": "Semantic code intelligence from real language servers.",
  "engines": { "yolop": ">=0.6" },
  "yolop": {
    "protocol_version": "1.0",
    "capabilityServer": { "command": "yolop-extension-lsp", "args": [] },
    "config_schema": { "$ref": "./config.schema.json" },
    "tools": [
      { "name": "lsp_definition", "never_defer": true },
      { "name": "lsp_references", "never_defer": true },
      { "name": "lsp_hover",      "never_defer": true },
      { "name": "lsp_diagnostics","never_defer": true },
      { "name": "lsp_rename",     "never_defer": true, "streaming": true },
      { "name": "lsp_symbols",    "never_defer": true },
      { "name": "lsp_code_actions","never_defer": true }
    ],
    "mcpServers": [],
    "hooks": []
  }
}
```

- **One scope: global.** Packages live in
  `<config_dir>/yolop/extensions/<name>/`, installed per user; malformed
  packages warn, never sink startup. There is deliberately **no workspace
  scope** (no `.agents/extensions/` discovered from repos): a repository
  must not carry agent-specific machinery — committing yolop extensions to
  a repo would quietly couple that project to one agent. Projects keep
  using the agent-neutral surfaces they already have (`.mcp.json`,
  `.agents/skills/`, `.agents/hooks.json`); a project README may *recommend*
  extensions, and the user installs them once, globally, by choice.
- **Naming convention**: crates.io extensions are named
  `yolop-extension-<name>` (the `cargo-<subcommand>` pattern). The suffix is
  the extension name — crate `yolop-extension-lsp` is extension `lsp`,
  capability ref `ext:lsp`. The prefix gives yolop a de-facto namespace and
  catalog on crates.io with no registry of its own: prefix search is
  discovery (`/extensions search <term>` can ride the crates.io API later),
  and squatting/typo risk is scoped to one greppable prefix. Crates should
  also set `keywords = ["yolop-extension"]`.
- **Install**: `/extensions install <source>`, plus
  `list | update | remove | enable | disable | doctor` — System commands
  *and* capability tools, so both the user and the model can drive setup.
  Sources, all pinned in `extensions.lock` and updated only explicitly:
  - `<name>` (bare) — shorthand for `crates.io:yolop-extension-<name>`;
    `/extensions install lsp` is the whole UX for the common case.
  - `<git-url>[@rev]` — cloned into the global dir; lock records source,
    resolved commit, content hash. Carries the package; the server binary
    must already be resolvable (PATH or the package's `bin/`) — a missing
    binary is a call-time tool error with install guidance, exactly like a
    missing `rust-analyzer` today.
  - `crates.io:<crate>[@version]` — provisions package *and binary* in one
    step, **with no cargo or Rust toolchain required**. The pieces that
    don't need a toolchain are compiled into yolop: the sparse index
    (`index.crates.io`) is plain HTTPS + JSON and the `.crate` file is a
    checksummed tar.gz, so yolop resolves the version, downloads the
    tarball, verifies the registry checksum, and reads the extension
    manifest from it — `plugin.json` at the crate root or
    `[package.metadata.yolop]` in `Cargo.toml`**without executing
    anything**, preserving the D4 invariant. Binary provisioning after
    consent, in order:
    1. **Prebuilt artifact (primary).** The crate's metadata names
       per-platform release artifacts (URL template + sha256 per target,
       `cargo-binstall`-compatible metadata accepted); yolop downloads the
       artifact for the host target, verifies the digest, and unpacks it
       into the package dir. Chain of custody: the digests live inside the
       registry-checksummed crate, so the lock's crate checksum transitively
       pins the binary.
    2. **`cargo install --locked --root <package dir>` (secondary).** Only
       if no artifact matches the host target *and* a toolchain happens to
       be present.
    3. Otherwise: install completes package-only and the missing binary is
       a call-time tool error with guidance — same policy as git installs.
    Lock records crate, version, registry checksum, and the artifact
    digest actually installed.
    *Implemented today:* the toolchain-free fetch (sparse-index resolve →
    CDN download → SHA-256 verify → unpack) and **step 3** (package-only;
    the server binary must be on `PATH` or in the package `bin/`). The
    prebuilt-artifact and `cargo install` provisioning steps (1–2) remain
    design-of-record.
  - `<path>` — referenced in place, not copied (dev loop).
- **Trust**: install is consent by action (same stance as authoring
  `.mcp.json`), preceded by a printed contribution summary (server command,
  tools, hooks, MCP servers, prompt size) — readable straight from the
  manifest, without executing the binary. `update` shows a **contribution
  diff** against the approved manifest and re-asks when the grant widened
  (new tools, new hooks, a changed server command); a hash-identical update
  is silent. Hooks and prompt text are the sharpest injection edges and are
  named explicitly in both summaries. No sandbox is claimed in v1.

## Illustration: `lsp` as a capability server

Decomposition of the existing capability:

- **Data plane** — rust-analyzer, gopls, pyright, … : already subprocesses;
  now spawned and kept warm by the extension process instead of by yolop.
- **Control plane**`yolop-extension-lsp` (crate name per the convention;
  binary ditto), a standalone binary on the reference SDK:
  the transport-generic LSP client, server lifecycle manager,
  position-encoding conversion, and workspace-edit safety checks move there
  ~verbatim (`client.rs` is already transport-generic; only `manager.rs`
  knows processes). It exposes the seven `lsp_*` tools natively over YEP —
  streaming suits `lsp_rename` (per-file progress on large workspaces).
- **Package** — manifest above + the directive prompt text (verbatim: the
  eval showed the "call `lsp_*` FIRST" wording is load-bearing) + the same
  config schema (`servers.<key>.command/args/extensions`, timeouts). Config
  changes to the servers map answer `restart-required`, matching today's
  "rebuild manager, killing old servers" behavior. `workspace/changed`
  re-roots the client, matching today's `WorkspaceHost` repointing.

Migration gate: the built-in stays until the extension reaches parity on
`evals/lsp_integration` (pass rate, `lsp_*` adoption, turns, tokens, plus
tool-call latency overhead measured). Parity → retire the in-tree
capability; the eval is the gate, the extension is the dogfood — for both
the protocol and the SDK.

## Future facets on the same protocol

- **LLM providers.** Tier 1 is declarative (OpenAI-compatible descriptor:
  base_url, auth env, model list/profiles) once `Provider` moves from a
  closed enum to a catalog. Tier 2 rides the handshake: the server announces
  `provider: { baseUrl: "http://127.0.0.1:<port>/v1", models: [...] }` — an
  OpenAI-compatible endpoint it hosts (the pattern Ollama normalized;
  `codex_driver`'s `DriverId::external` shows the driver-side seam). Native
  drivers stay compiled in.
- **UI features.** Extensions never run TUI code. `ui/ask` bridges to
  `user_ask` forms now; if richer needs appear, handshake-declared component
  *trees* (upstream `a2ui`/`openui` precedent) rendered by yolop's own
  widgets.
- **everruns plugins.** A package without the `yolop` facet *is* an everruns
  plugin and loads through the upstream declarative path; the facet is
  additive. If upstream grows capability-server support, YEP is the candidate
  to upstream under a neutral name once proven here.

## Alternatives considered

- **MCP superset** (previous revision's recommendation) — rejected: the
  contract would be shaped by, and forever versioned under, a protocol that
  lacks tool streaming, hooks, prompt policy, and config semantics; the
  cross-host benefit is achieved better by composition — extensions
  *provide* MCP servers through the handshake instead of *being* one.
- **Pure MCP with no extension surface** — rejected as the ceiling goose
  already demonstrates: tools only, no capability-level contract. Still
  fully supported for plain tool servers via `.mcp.json` and via
  extension-provided MCP servers.
- **Rust dylibs** (`abi_stable`/`stabby`) — rejected: no stable ABI,
  toolchain lockstep, per-platform artifacts, no isolation.
- **In-process scripting** (pi/OpenCode model; upstream `lua` exists) —
  deferred: single language, engine surface, and a sandbox liability for
  exactly the FS/process access the LSP class needs.
- **WASM components** (wasmtime + WIT / Extism) — deferred, and *scoped*: it
  is the untrusted-marketplace endgame and the only honest home for the
  excluded hot-loop seams (D3), but WASI cannot spawn the data plane, and
  the host machinery (per-version WIT snapshots) is Zed-scale. When it
  comes, a WASM module is one more facet in this same package format, not a
  new unit.

## Rollout

1. **Protocol core + schema + SDK.** `initialize`/`initialized`,
   `tool/call` + `tool/update` + `cancel`, `shutdown`; wire types +
   schema-gen + `schema/yep/v1/` with CI `--check` and a conformance
   corpus; the `ExtensionCapability` adapter; the `yolop-extension`
   reference crate; `/extensions doctor`. Exit: a hand-built server passes
   conformance and its tools stream in the TUI.
2. **Packaging + trust.** Global-scope discovery, `extensions.lock`,
   `/extensions` verbs, manifest-clamps-handshake enforcement,
   update-time contribution diffs, config schema into the catalog +
   `config/changed`. Exit: install from a git URL to working tools in one
   command.
3. **Contributed MCP servers.** Handshake `mcpServers` merged into scoped
   MCP config through the existing client; name/transport clamping. Exit: an
   extension wrapping a third-party MCP server (credentials + lifecycle)
   works end to end.
4. **Hooks + dynamic prompt + ui/ask.** `hook/fire`, `prompt/contribution`,
   `ui/ask`, `workspace/changed`. Exit: a guardrail-style reference
   extension (block writes to generated files) works.
5. **`yolop-extension-lsp` dogfood.** Extract the control plane onto the SDK; A/B on
   `evals/lsp_integration`; parity retires the built-in.
6. **Providers.** Descriptor tier after the Provider-catalog refactor;
   provider handshake facet after.

## Non-goals

- No in-process native code, ever (ABI analysis above).
- No TUI code from extensions.
- No hot-loop seams over the wire (facts, message filters, model views,
  tool-definition transforms) — see D3.
- No workspace scope — repos never carry (or auto-discover) yolop
  extensions; projects stay agent-neutral.
- No hot reload, central registry, or sandbox claims in v1.
- No second hook engine, skills format, or enable/disable surface — every
  facet lands on an existing seam.

## Open questions

- Namespace: `ext:<name>` vs reusing upstream `plugin:<name>`. `ext:` keeps
  "hydrates from serialized config" (upstream plugin semantics) distinct
  from "proxied live process", at the cost of a second prefix; decide when
  the loader lands.
- Multiplex several sessions over one server process (LSP servers are
  expensive to duplicate) vs process-per-session (simpler isolation)? Yolop
  is effectively single-session per TUI today; background sessions may force
  the choice. The protocol reserves `session_id` on every request either
  way.
- `never_defer` budget size, and whether it should be model-adaptive
  (mirroring `auto_tool_search`).
- Hook RPC while the server is crashed: fail-open with warning
  (availability) vs fail-closed per `on_error` (integrity). Leaning: honor
  the subscription's declared `on_error`, same as hook timeouts.
- Whether contributed *stdio* MCP servers should get a persistent connection
  mode (today's transport is spawn-per-call), or whether stateful cases
  should always be extension-hosted HTTP endpoints.