diff --git a/.editorconfig b/.editorconfig
index 6cf65c4e..53ce7662 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -682,8 +682,8 @@ dotnet_diagnostic.RCS1141.severity = suggestion
# SECTION 12: SONARANALYZER (Sxxxx)
# =============================================================================
# Policy: only arbitrated rules are listed; everything else rides SonarWay
-# defaults, which TreatWarningsAsErrors escalates to error (chosen fail-closed
-# posture, 2026-08-08). When a Sonar rule misfires, arbitrate it here with a
+# defaults, which TreatWarningsAsErrors escalates to error. When a Sonar rule
+# misfires, arbitrate it here with a
# comment naming the winner.
dotnet_diagnostic.S125.severity = suggestion
dotnet_diagnostic.S2094.severity = suggestion
@@ -700,7 +700,7 @@ dotnet_diagnostic.S6602.severity = none
# SECTION 13: COMPILER DIAGNOSTICS (CSxxxx)
# =============================================================================
# Every public member on the shipped surface is documented: generated docs come from the
-# spec, hand-written docs by hand (ADR-0004). Tests are exempted in Section 15.
+# spec, hand-written docs by hand. Tests are exempted in Section 15.
dotnet_diagnostic.CS1591.severity = error
# =============================================================================
@@ -735,8 +735,8 @@ dotnet_diagnostic.VSTHRD111.severity = none
# =============================================================================
# Placed after every global C# diagnostic section so the file-scoped glob never
# narrows a later section. MA0048 (file name = type name) cannot apply to the
-# file-based tool entry: the SDK synthesizes the Program type, and the sealed
-# entry contract pins the file name (it is the tool name:
+# file-based tool entry: the SDK synthesizes the Program type, and the entry
+# contract pins the file name (it is the tool name:
# ./tools/opencode-tool.cs). Winner: the entry contract; the rule stays error
# for every other file, generated output included (emission keeps file = type).
[tools/opencode-tool.cs]
@@ -785,16 +785,34 @@ dotnet_diagnostic.S2325.severity = none
[tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralKind.cs]
dotnet_diagnostic.CA1720.severity = none
-# Generated response adapters gain shared, non-virtual mapping helpers on this
-# base at emission build-out, and default interface members cannot carry shared
-# behavior on the locked net472/netstandard2.0 targets (CS8701). Winner: the
+# The abstract adapter base carries shared, non-virtual mapping behavior for the
+# generated response adapters, and default interface members cannot carry shared
+# behavior on the net472/netstandard2.0 targets (CS8701). Winner: the
# abstract adapter base; S1694 remains enabled for every other file.
[src/OpenCode.Sdk/Internal/ResponseAdapter.cs]
dotnet_diagnostic.S1694.severity = none
-# The generated route surface deliberately nests one static container per operation group
-# (maintainer-approved shape, 2026-08-13); flattening would erase the grouping the public
+# The generated route surface deliberately nests one static container per operation
+# group; flattening would erase the grouping the public
# API promises. Winner: the generated route shape; CA1034 remains enabled for every other
# file.
[src/OpenCode.Sdk/OpenCodeRoutes.cs]
dotnet_diagnostic.CA1034.severity = none
+
+# The shipped SDK slices by client family with flat public namespaces:
+# client-family folders are placement, a namespace is API surface, and the
+# test project mirrors the layout with equally flat test namespaces. Winner: the
+# flat-namespace contract; IDE0130 remains error for every other folder, tools included.
+# A new family folder extends these globs deliberately - curation changes are API reviews.
+[src/OpenCode.Sdk/{Sessions,Health,Pagination}/**.cs]
+dotnet_diagnostic.IDE0130.severity = none
+
+[tests/OpenCode.Sdk.Tests/{Sessions,Health,Pagination}/**.cs]
+dotnet_diagnostic.IDE0130.severity = none
+
+# DI extension methods live in the Microsoft.Extensions.DependencyInjection namespace so
+# consumers get AddOpenCode without an extra using - the ecosystem convention every
+# companion DI package follows. Winner: the ecosystem
+# namespace contract; IDE0130 remains error for every other Extensions file.
+[src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs]
+dotnet_diagnostic.IDE0130.severity = none
diff --git a/.gitignore b/.gitignore
index 092bc6e3..158aad67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -417,7 +417,6 @@ internal-docs/
!.claude/skills/
!.claude/skills/**
-external/
tmp/
.fake
diff --git a/.gitmodules b/.gitmodules
index 1bd4ab1a..1b98a172 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -5,3 +5,4 @@
path = external/opencode
url = https://github.com/anomalyco/opencode
shallow = true
+ branch = v2
diff --git a/Directory.Build.props b/Directory.Build.props
index 621f8169..8a58aec7 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -3,7 +3,7 @@
net10.0netstandard2.0;net472;net8.0;net9.0;$(DefaultTargetFramework)
-
14.0enable
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 769b76b8..d7e0ac8e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -15,7 +15,12 @@
+
+
+
+
+
diff --git a/OpenCode.slnx b/OpenCode.slnx
index 2254cb7a..9df1a160 100644
--- a/OpenCode.slnx
+++ b/OpenCode.slnx
@@ -16,12 +16,15 @@
+
+
+
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 9220ea5e..da500213 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,6 +1,6 @@
# Roadmap
-Date: 2026-08-13
+Date: 2026-08-15
Operational state: what is done, what is next, what is open. This file shrinks as work lands.
Evergreen rules and locked decisions live in `../AGENTS.md`; decision records in `adr/`.
@@ -27,7 +27,38 @@ the blocker set (typed-spine leaks, silent route rewriting) lands on the PR bran
issues #19–#25 pinned to the milestone that resolves it — nothing on that list outlives the
M series.
-The next work is M2 — the first breadth batch, planned when it starts.
+**M2's first breadth batch is complete** (plan:
+`superpowers/plans/2026-08-14-m2-first-breadth-batch.md`; decisions: research log Sessions
+19–20). `session.list`, `session.get`, `session.create`, and `message.list` are callable
+through their final generated surface — uniform `*Request` operation inputs (Q83) with the
+query records riding the `ListRequest` seam, `SessionCreateRequest` bodies through the
+pipeline's JSON path, cursor-list envelopes with the shared `ListCursor`, query-composing
+routes, the `Session.Info` model closure, and the first 5xx arm. Riders #19 (carrier converters), #21 (fail-closed walls + P2 single-pass
+envelopes: `GetMessageAsync` 67.4→56.2 μs, `ListMessagesAsync` baseline 58.1 μs/28.24 KB),
+and #22 (List/Create verb rules + C17–C20) landed with it; upstream's `InvalidRequestError1`
+duplicate collapses through the new `schemaAliases` curation. Demonstrated live 2026-08-14
+against `opencode2 serve` v0.0.0-next-17403 (create → list → get → messages, wire cursor
+round-tripped). The #20 decision landed (blank explicit passwords refuse; the environment
+fallback was later removed by Q90 — `null` sends anonymous requests) and #25 closed keep. The alignment batch is complete — the uniform
+`*Request` rename (Q83), the feature-slice layout migration (Q84), and the Extensions
+bring-up (Q85) — and the follow-on construction/options/DI reshape (research log Q90)
+landed on the same PR: options-only construction with the read-only
+`IOpenCodeClientOptions` view and configurable `Username`, no SDK environment reads,
+`IHttpClientFactory`-based `AddOpenCode` returning the `IHttpClientBuilder`, pooled
+connection lifetime on the owned transport, and the sandbox as the Generic Host DI
+showcase. The PR #26 external review ran through adversarial verification (36 findings:
+30 confirmed / 4 plausible / 2 refuted), and the verified fix batch landed 2026-08-15
+red-test-first — all ten merge blockers plus the small confirmed fold-ins, with Q91 sealed
+(research log Session 23, doc 16: the caller-owned HttpClient constructor stays public
+behind a fail-closed anonymous-mode guard). A second full-diff review (15 verified
+findings) closed the same day: six runtime/emitter fixes landed on the branch (timeouts
+route onto the transport spine, conflicting BaseAddress refuses, handle guards match the
+route guards, carrier payload guards, count-guard identity, the Pagination shadow wall)
+for 932 green tests, and every surviving finding lives milestone-anchored with an explicit
+trigger — #31 (M2, DI lifetime shape, decision-first), #32 (M3, EscapeDataString TFM
+policy), #33 (M3, carrier hand-construction semantics, rides #23), #34 (M2, contract-test
+consolidation), plus the #27 and #23 enrichments. Merge is awaiting the maintainer's word;
+further breadth batches follow merge.
## Milestones
@@ -45,16 +76,22 @@ is revisited at each milestone boundary.
`superpowers/specs/2026-08-11-production-walking-skeleton-design.md`.
2. **M2 — Breadth batches.** The generation profile grows in vertical operation batches;
each batch lands its curation rows, reachable models, operation methods, and contract
- tests together. Opens with the review hardening batch (#19 `Unknown*` serialization,
- #20 password-semantics decision, #25 IVT decision); the generator fail-closed walls
- batch (#21, includes the P2 envelope fold) and the naming/curation wall batch (#22)
- land at this boundary.
-3. **M3 — Streams.** SSE engine over the v2 stream surface (`v2.event.subscribe`,
+ tests together. The first batch (list/get/create/message-list) is complete with every
+ review rider resolved (#19–#22, #20, #25), and so is the alignment batch (uniform
+ `*Request`, feature-slice layout, Extensions bring-up — research log Q83–Q85). The
+ Extensions package grows in parallel with the remaining batches.
+3. **M3 — Streams.** Planning opens with the **location + merged-Request input design
+ session** (sealed 2026-08-14, research log Session 22): the dual-channel location
+ mechanism, deepObject marshalling, the one-`*Request`-carrying-body-and-query shape,
+ and the `session.list` flat-field exception — census and mechanisms in research doc 15
+ §5a/§6. Then the SSE engine over the v2 stream surface (`v2.event.subscribe`,
`v2.session.log` with `after`/`follow`, cursor-paged `v2.message.list`); the v1
durable-stream design does not carry over and is re-derived here. Demo: watching a
real session's event stream. The net472 `ServicePointManager` item lands here. The
union single-pass deserialization and streaming adapter-boundary redesign (#23) land
- on the M3 runway, gated on the performance baselines (#18).
+ on the M3 runway, gated on the performance baselines (#18), together with the
+ second-review perf mechanisms and #29; #32 (EscapeDataString TFM policy) rides the
+ net472 cluster and #33 (carrier hand-construction semantics) rides #23.
4. **M4 — Launcher.** `OpenCodeServer.StartAsync` with three-OS acceptance (ADR-0001)
over `opencode2 serve`; demo: the SDK starts the server itself and calls health. The
net472 stdout/tree-kill items land here. (`serve --stdio`'s stdin leash and the
@@ -62,9 +99,9 @@ is revisited at each milestone boundary.
platform detail: research doc 15.)
5. **M5 — Full surface.** Complete generation profile over the protocol surface,
exclusion fingerprints (ADR-0008), packaging unblocked.
-6. **M6 — Operational closure.** `refresh-spec`, Extensions DI breadth,
- retry/telemetry/hooks, quarantine lane, nightly canary (the performance suite joins
- it); durable decisions distill into ADRs and the `superpowers/` documents retire. Any
+6. **M6 — Operational closure.** `refresh-spec`, retry/telemetry/hooks, quarantine
+ lane, nightly canary (the performance suite joins it); durable decisions distill
+ into ADRs and the `superpowers/` documents retire. Any
hygiene-sweep leftovers (#24) are resolved here — nothing from the review queue
survives the M series.
diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-13-3.md b/docs/agents/handover-prompts/HANDOFF-2026-08-13-3.md
deleted file mode 100644
index a15624bf..00000000
--- a/docs/agents/handover-prompts/HANDOFF-2026-08-13-3.md
+++ /dev/null
@@ -1,75 +0,0 @@
-# Post-M1 Handoff — Ship The Branch, Then Open M2
-
-Date: 2026-08-13
-
-Consume this document against live Git and delete it when the branch merges and the M2 batch
-plan exists. It records the queue between M1 (complete) and M2 (not yet planned).
-
-## Onboarding Reading Order
-
-1. `AGENTS.md`, then ADRs 0003/0004/0005/0007/0008/0009.
-2. `docs/ROADMAP.md` — M1 is complete; the status paragraph is the current-state summary.
-3. Research log Sessions 17–18 — the retarget chain and the Arc B execution decisions
- (mechanical payload names, User-Agent policy, route shape).
-4. `tests/OpenCode.Sdk.Sandbox/README.md` — the committed debug playground.
-
-## Git And Workspace State
-
-| Item | State |
-|---|---|
-| Branch | `feature/m1-callable-client`, ~26 commits ahead of `origin/master`, never pushed |
-| Gates | All six green at every commit; pack fails only on `.generation-incomplete` |
-| Tests | 526 across tools + SDK legs (net8/9/10 locally; net472 only on Windows CI) |
-| Demo evidence | `.scratchpad/m1-demo/DEMO.md` (gitignored) — paste into the PR body |
-
-Gate chains need `set -o pipefail` plus an explicit ALL-GREEN marker — a bare `| tail`
-swallows a red gate's exit code.
-
-## Step 1 — Ship the branch
-
-1. **Multi-agent review (maintainer-triggered):** the maintainer runs `/code-review ultra`
- on this branch — it is user-invoked and billed, an agent cannot start it; the no-arg form
- bundles the local branch and needs no remote. Triage findings red-test-first; anything
- touching public API names goes back to the maintainer before changing the baseline.
-2. **Push + PR to `master`** (push only on the maintainer's explicit ask). PR body: the M1
- story, the `DEMO.md` transcript, and the pin-vs-installed version skew note
- (`a6a712a` vs `opencode2` v0.0.0-next-17403 — deliberate snapshot policy).
-3. **Watch first-contact CI.** This work has never seen the three-OS matrix. Known risk: the
- Windows-only net472 test leg renders the shared `PublicApi.verified.txt` from embedded
- (Polyfill-supplied) attributes — if it diverges, split the baseline per TFM or scrub the
- attribute rendering; do not weaken the baseline test. `App.net472.config` must never flow
- into the package when packaging is later enabled.
-
-## Step 2 — Open M2 (first breadth batch)
-
-Write the 1–2 page batch plan when the branch merges, not earlier (ROADMAP rule). The
-standing proposal to put before the maintainer: **`v2.session.list` + `v2.message.list` +
-`v2.session.get`** — it converts the demo's raw curl calls into SDK methods and pulls in the
-real M2 machinery:
-
-- Query-parameter binding (drops the M1 `no query` wall) including optional parameters
- (`limit`, `order`, `cursor`) and the deferred `location[...]` scoping decision.
-- List payloads (`IReadOnlyList`) and the `{cursor, data}` / `{data, hasMore}` envelopes;
- the cursor/paginator shape from the API design spec is a **public API review**.
-- The `List*` verb rule joining `OperationNamePolicy` (collection GETs currently name as
- `Get*`).
-- Consider a deliberate pin refresh at this boundary (M2 boundary is the sanctioned moment;
- drift lands as reviewable git diffs).
-
-## Step 3 — Horizon (order revisited at each milestone boundary)
-
-- **M3 streams:** SSE engine; `v2.session.log` (`after`/`follow`) semantics re-researched.
-- **M4 launcher:** `OpenCodeServer.StartAsync` over `opencode2 serve`; discovery-file and
- `--stdio` leash are candidate mechanisms.
-- **M5 full surface:** complete profile, exclusion fingerprints (ADR-0008), packaging
- unblocks.
-- **M6 operational closure:** `refresh-spec`, Extensions DI (the sandbox grows its Generic
- Host root then), retry/telemetry/hooks.
-- Standing chores: the manual analyzer-package bump routine (nothing warns when it lags);
- v2 GA watch (research doc 15's open items).
-
-## Stop Conditions
-
-Public-API taste decisions go to the maintainer as options with a recommendation. The Arc B
-stop-condition list (binder needing raw OpenAPI, name-branching emitters, structural unions,
-ownership ambiguity, TFM breakage, partial packs) carries into M2 unchanged.
diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-15.md b/docs/agents/handover-prompts/HANDOFF-2026-08-15.md
new file mode 100644
index 00000000..fc72a7cd
--- /dev/null
+++ b/docs/agents/handover-prompts/HANDOFF-2026-08-15.md
@@ -0,0 +1,129 @@
+# Session Handoff — M3 pickup: plan-first opening after the M2 first-breadth merge
+
+Date: 2026-08-15
+
+Consume this document against live Git; delete it when the M3 plan is written and agreed.
+It supersedes `HANDOFF-2026-08-14-3.md` (deleted with this one's creation): that handoff's
+whole queue completed — Q91 sealed, the ten verified merge blockers plus fold-ins fixed
+red-test-first, a second full-diff review ran and closed the same day (six more fixes on
+the branch, everything else issued with triggers), and PR #26 merged. What remains is the
+small #34 test-consolidation PR and the M3 plan.
+
+## What the closing session decided (beyond the docs)
+
+- **Q91 sealed** (research log Session 23, doc 16): the caller-owned `(HttpClient,
+ options)` constructor stays public; anonymous mode refuses a default `Authorization`
+ fail-closed at construction and per send; with a password set the SDK's per-request
+ header wins and the precedence is documented.
+- **Second review (15 verified findings)**: six landed on the branch — timeouts wrap into
+ `OpenCodeTransportException` (caller-token-untouched cancellation only), conflicting
+ injected `BaseAddress` refuses loudly, handle construction applies the shared
+ route-value guard (`EmissionSyntax.RouteValueGuard`), carrier constructors refuse
+ unparsed payloads, the count guard carries the `request` parameter identity, and
+ `Pagination` joined the writer's shadow wall. Every surviving finding lives
+ milestone-anchored **with an explicit Trigger section** in #31–#34 and the #27/#23
+ enrichment comments — that trigger convention is now the standing shape for
+ review-queue issues.
+- **Comment hygiene is enforced**: comments in code artifacts state the status quo only —
+ no doc/ADR citations, no decision dates/attribution, no milestone scoping, no future
+ promises, no review-finding ids (repo-wide sweep, commit `7ac5bf3`). Member ordering is
+ a repo rule: access modifier first, then usage order.
+- **MockSeam stays** as-is for now; the instructive mock guard has no ecosystem precedent
+ (gRPC/Google throw messageless `NotImplementedException`; Azure/OpenAI ship nothing)
+ and is a deliberate defensive refinement. A rename (`MockingConstructorGuard`-style)
+ remains an open low-stakes option; note it at M6 if the maintainer wants it.
+
+## Git, Workspace, And Issue State
+
+| Item | State |
+|---|---|
+| Branch | `master` after the PR #26 merge; `feature/m2-first-breadth-batch` retired |
+| Tests | 932 across all legs; all six gates green at every commit |
+| Issues (all trigger-annotated) | **#31** M2, DI lifetime shape — decision-first; **#32** M3, EscapeDataString TFM policy; **#33** M3, carrier hand-construction semantics (rides #23); **#34** M2, contract-test consolidation (ready-for-agent); **#27** +5 wall gaps (R2-8..12; R2-8 likely fires next batch); **#23** + the cursor-list re-copy mechanism; #28/#29/#30/#24 unchanged |
+| CI | Three-OS matrix healthy again (an Actions spending-limit outage on 2026-08-15 was billing, not code) |
+
+## Onboarding Reading Order
+
+1. `AGENTS.md`, then every ADR (0001–0009).
+2. Research log Sessions 19–23 (Q76–Q91) — the M2 seals, the Q90 reshape, the upstream
+ re-research, and the Q91 BYO-transport seal (census in doc 16).
+3. `docs/engineering/coding-style.md` and `testing-style.md`.
+4. `docs/ROADMAP.md` — status, milestone anchors, open questions.
+5. Research doc 15 §5a/§6 — the location census the M3 design session consumes.
+
+## Work Queue (in this order)
+
+### A. #34 — contract-test consolidation (small standalone PR)
+
+Ready-for-agent; land it before the next batch multiplies contract tests. One shared
+scaffold home per testing-style §1, single `FixtureLoader`/`RecordingHttpHandler` pair
+across test projects, wire bodies out of inline literals, plus the missing
+object-envelope `{"data":null}` contract test.
+
+### B. M3 plan — write the short (1–2 page) plan first; align before any execution
+
+Planning opens with the **location + merged-Request input design session** (sealed:
+Session 22 Q88). Census facts to design against (doc 15 §5a/§6): 61 deepObject
+`location` ops; all 15 body+query mixes are location-caused; `session.list` is the flat
+exception; server precedence query > header. The body+query wire-shape refusal landed in
+the fix batch — admission waits on this design.
+
+M3 scope pointers beyond that session:
+
+- SSE engine (`v2.event.subscribe`; `v2.session.log` with `after`/`follow` — resume
+ guarantees still unestablished), stream endpoints hand-wired per ADR-0008.
+- The paginator over the `ListRequest` seam and `ListCursor`.
+- The net472 cluster: `ServicePointManager` hardening **and #32** (EscapeDataString TFM
+ policy — refuse/normalize/tolerate needs a seal).
+- **The deliberate performance pass** (see below).
+- #33's carrier hand-construction contract decides while #23 rewrites the converters.
+- Spec-pin refresh is sanctioned at this boundary (submodule pointer moves in lockstep —
+ `spec/SNAPSHOT.md` procedure).
+
+Decision-first items to put before the maintainer early: #31 (DI lifetime shape — M2
+anchored, must seal before Extensions grows), #28 (curation mutual-exclusion), #32, #33.
+
+### C. Performance notes (maintainer-directed, 2026-08-15)
+
+- **SDK performance work lands at M3, deliberately, in one pass**: the three confirmed
+ hot-path mechanisms — UTF-16 body materialization + re-transcode, union converter
+ JsonDocument double-parse, cursor-list re-copy after converter validation — all
+ converge on #23's adapter-boundary redesign. Do them together under `MemoryDiagnoser`,
+ allocation-first, with before/after numbers in the PR (posture: benchmarks settle
+ claims, never argument). Doing them before the SSE engine reshapes buffering would be
+ double work.
+- **Until then the discipline is baseline-keeping, not optimizing**: the perf project's
+ baselines (`GetMessageAsync` 56.2 μs; `ListMessagesAsync` 58.1 μs / 28.24 KB) must not
+ regress through breadth batches.
+- **Tooling performance waits for M6** (`refresh-spec` + nightly canary; posture: speed
+ only, no allocation budget). Early trigger: the regen ritual noticeably slowing the
+ dev loop pulls #24's measured-first items (V31/V32) forward.
+
+## Session Mechanics The Docs Don't Carry
+
+- Dev loop: all six gates before every commit under `set -o pipefail` with an explicit
+ ALL-GREEN marker; red-test-first; commit per green increment; push on the working
+ branch authorized inside an agreed loop; decisions to the maintainer as plain text with
+ options and a recommendation — never dialog widgets; engage their notes in prose
+ before touching code.
+- Regen ritual: emitters → `dotnet run --file tools/opencode-tool.cs -- generate` →
+ review the committed diff → snapshots received→verified after review →
+ `generate --verify` before commit.
+- Review-queue issues carry a **Trigger** section and a milestone in the title; full
+ finding text goes into the issue body so nothing lives only in transient scratchpads.
+- Multi-TFM traps are recorded in agent memory; Sonar's test-project probe misses
+ TUnit-only projects (arbitrate via csproj `NoWarn` + one-line slopwatch-ignore).
+- The maintainer reads answers, not tool traffic; batch decisions, lead with the
+ recommendation.
+
+## Stop Conditions
+
+The prior list carries (a binder needing raw OpenAPI access, name-branching emitters,
+structural unions, ownership ambiguity, TFM breakage, partial packs; any body+query
+operation admitted without the sealed merged-Request design; Extensions dependencies
+outside research doc 06's policy; any M3 stream design bypassing plan-first), plus:
+
+- Changing the Extensions registration shape before #31's decision seals.
+- Any curation surface without a maintainer seal (#28 still waits).
+- Optimizing SDK hot paths outside the #23-anchored M3 pass, or claiming performance
+ without benchmark numbers.
diff --git a/docs/engineering/coding-style.md b/docs/engineering/coding-style.md
index 528ead19..1d2c53b6 100644
--- a/docs/engineering/coding-style.md
+++ b/docs/engineering/coding-style.md
@@ -119,8 +119,19 @@ for a pile of static steps.
folder) for data shapes, `Abstractions/` for its seams, implementation classes at
the slice root. Similar things sit together; a reader predicts where a type lives
before opening the tree.
-- File = type (MA0048) and folder = namespace (IDE0130) are already mechanical; this
- document adds the *placement* discipline analyzers cannot see.
+- **The shipped SDK slices by client family with flat public namespaces** (maintainer,
+ 2026-08-14): client families are folders (`Sessions/`, `Health/`), the pagination
+ spine sits under `Pagination/`, wire models under `Models/`, runtime internals under
+ `Internal/`, and the root client with the response/exception spine at the project
+ root. Public namespaces stay `OpenCode.Sdk` and `OpenCode.Sdk.Models` — a namespace
+ is API surface, folders are placement (Stripe/Azure precedent) — so IDE0130's
+ folder-matches-namespace rule is arbitrated for the SDK's public folders through the
+ standing per-rule pattern.
+- **A test project mirrors the layout of the project under test**: the folder path of a
+ SUT predicts the folder path of its tests.
+- File = type (MA0048) is mechanical everywhere; folder = namespace (IDE0130) is
+ mechanical outside the SDK's arbitrated public folders. This document adds the
+ *placement* discipline analyzers cannot see.
- **Split before it hurts:** a class approaching a few hundred lines, or a folder
flattening into dozens of siblings, is a slice asking to be verticalized. Growth is
handled by extraction and sub-foldering at the moment of pressure, not by a rewrite
diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md
index b11e84a5..45d525b6 100644
--- a/docs/research/00-research-log.md
+++ b/docs/research/00-research-log.md
@@ -1657,3 +1657,281 @@ is arbitrated for the one generated file. Route member names ride the plan
**Documentation pass (this session):** ROADMAP moved to M1-complete/M2-next; the Arc B plan's
checkboxes closed in the same commits as their code; the consumed handover was deleted and a
fresh one (`HANDOFF-2026-08-13-3.md`) records the ship/M2/horizon queue.
+
+# Session 19 — 2026-08-14: M1 ship, review triage, M2 opening decisions
+
+PR #16 merged after a verified multi-agent review cycle (blocker set fixed red-test-first
+on the branch; every surviving finding milestone-anchored in issues #17–#25) and the
+BenchmarkDotNet performance suite landed with baselines. This entry records the decisions
+sealed while opening M2; execution detail lives in git and the M2 plan.
+
+## Q76: What shape does the generated query surface take?
+
+**How researched:** the four M2 operations extracted from the pinned spec (session.list
+carries 9 optional `anyOf [T, null]` query parameters; both list operations share the
+`limit`/`order`/`cursor` trio and the `{data, cursor:{previous,next}}` envelope); Azure SDK
+.NET design guidelines fetched live ("DO use the options parameter pattern for complex
+service methods"; `Pageable`/`AsyncPageable` for lists); Stripe.net
+(`SessionListOptions : ListOptions` shared base) and OpenAI .NET (`*CollectionOptions`)
+as ecosystem precedent.
+
+**Decision (maintainer, sealed):** per-operation generated options records deriving from a
+shared `ListOptions` base that carries only the cursor-pagination trio; a fail-closed
+generator profile-detection wall derives an operation from the base only on an exact wire
+match (otherwise flat options); the base is the typed seam the M3 paginator will consume.
+`*Options` = call shaping, `*Request` = wire body.
+
+## Q77: How is the string-typed `limit` exposed?
+
+**Found:** the wire types `limit` as a string; `uint` was weighed and rejected — FDG bans
+unsigned types in public APIs (CLS compliance), the ecosystem has zero precedent
+(`Take(int)`, Stripe `long?`, OpenAI `int`), and it does not even buy the invariant
+(zero stays representable, so a guard is needed either way).
+
+**Decision (maintainer, sealed):** public `int?`, invariant-culture conversion at the
+route boundary, non-positive values refused with `ArgumentException`.
+
+## Q78: How does the API express parentID's root-only sentinel?
+
+**Found:** the wire has three states — omitted (all sessions), `parentID=` (children),
+and the literal string `"null"` (root sessions only). The schema shape is
+self-identifying: `anyOf` of a `^ses`-patterned string and a single-value `"null"` enum.
+
+**Decision (maintainer, sealed):** a hand-written public spine type
+`SessionParentFilter` (`RootOnly` singleton, `Of(id)` factory) carried by the options
+record; the binder recognizes the wire shape mechanically, never the parameter name
+(ADR-0008 stays intact); invalid combinations are unrepresentable at compile time.
+
+## Q79: What shape does the first request body take?
+
+**Decision (maintainer, sealed):** `session.create`'s inline all-optional body binds into
+a generated `SessionCreateRequest`; the operation parameter is optional —
+`CreateSessionAsync()` sends an empty JSON body. The `{Subject}{Verb}Request` pattern is
+the mechanical rule for future bodies.
+
+# Session 20 — 2026-08-14: M2 first breadth batch execution
+
+The batch executed per the sealed plan: binder walls opened red-test-first (query options
+with the profile-detection wall, request bodies, cursor-list envelopes, merged client
+families), the pipeline grew its JSON body path, the naming and fail-closed walls batches
+(#22, #21) and the F07 carrier converters (#19) landed, and the four operations were
+demonstrated live against `opencode2 serve` v0.0.0-next-17403 (create → list → get →
+messages, all typed 200s, wire cursor round-tripped). This entry records the decisions
+sealed during execution.
+
+## Q80: What are the concrete names of the query surface's supporting types?
+
+**How researched:** batched to the maintainer with previews once the binder was about to
+encode names; Stripe (`SessionListOptions`) and the sealed `{Subject}{Verb}Request` rule as
+anchors.
+
+**Decision (maintainer, sealed):** options records follow `{Group}{Verb}Options`
+(`SessionListOptions`, `MessageListOptions`), mirroring the request-model rule so one
+mechanical family covers both; the shared order enum is the hand-written spine
+`ListOrder { Ascending, Descending }` (request-only closed enum — ADR-0009 tolerance does
+not apply); the wire cursor surfaces through one hand-written `ListCursor` record on every
+list envelope, giving the M3 paginator a single cursor seam. The binder validates the wire
+shapes (`asc`/`desc` exactly; `{previous?, next?}` exactly) and fails closed otherwise.
+
+## Q81: How is upstream's InvalidRequestError1 duplicate resolved?
+
+**Found:** the plan's recon framed it as a naming dup; execution showed it byte-identical to
+`InvalidRequestError` *including the `_tag`*, which trips the per-status duplicate-tag wall
+and would poison `OpenCodeError` tag dispatch. Two candidate mechanisms were priced: a
+mechanical structural dedup (which needed a novel recorded-output channel to satisfy the
+maintainer's drift-visibility requirement) and a curated alias.
+
+**Decision (maintainer, sealed):** a `schemaAliases` curation section — one row declaring
+the duplicate a spelling of its target, validated fail-closed for deep structural identity
+and applied as a pure document transform before binding. Drift stays loud through existing
+machinery: a deleted source or target orphans the row, a dereferenced source goes dormant
+against the profile, and any structural divergence (the tag included) breaks the identity
+wall. The binder's own duplicate-tag refusals stay intact for anomalies without an alias.
+
+## Q82: How do the two options-like parameters coexist on generated methods?
+
+**Decision (maintainer):** the query record binds to `options` and `OpenCodeRequestOptions`
+binds to `requestOptions` on every generated method — the type's own name decides, matching
+Stripe's idiom; the pre-release rename of the existing surface rode the PublicApi baseline
+review.
+
+## Execution notes
+
+- The verb rules (sealed decision 5) concretized as: only the final identifier segment can
+ be a verb (`create`/`get`/`list` — the C18 structural fix); empty subjects fall back to
+ the group, pluralized for list operations under a naive fail-closed rule; response type
+ names fold non-Get verbs; client-placed route members mirror their method names while
+ root members keep the bare-verb shape (`Health.Get`).
+- The P2 single-pass envelope DTOs cut `GetMessageAsync` from 67.4 μs to 56.2 μs with flat
+ allocations; `ListMessagesAsync` baselines at 58.1 μs / 28.24 KB.
+- The contract matrix caught a real defect before any consumer could: the error-path
+ constructor pushed its forgiven null through the list payload's defensive-copy init
+ accessor; the copy now passes the forgiven null through uncopied behind the guard.
+
+## Q83: Do operation inputs stay split across *Request and *Options?
+
+**How researched:** maintainer review question (AWS-style Request/Response symmetry);
+ecosystem survey — AWS and Google Cloud use a uniform `{Operation}Request` for every
+operation regardless of wire placement (query vs body is a marshalling detail), Stripe and
+OpenAI use a uniform `*Options` (POST bodies included), Azure's options bag carries only
+optional inputs; **no surveyed SDK splits the suffix by verb** as Q76's dichotomy did.
+
+**Found:** the sealed dichotomy had married halves of two different families, and the
+verb→suffix mapping is unstable — a future query-carrying POST would demand two input bags
+per call where the uniform-Request shape carries one.
+
+**Decision (maintainer, sealed):** uniform `*Request`, revising Q76's naming half. Query
+records rename (`SessionListRequest`, `MessageListRequest`, base `ListRequest`); the body
+models already carry the name; the method and route-builder parameter becomes `request`.
+The profile-detection wall and the M3 paginator seam carry over under the new name. An
+operation declaring both a body and query parameters would mechanically derive one type
+name twice and fail the existing collision wall — deliberate, until a merged-Request
+design (AWS marshalling style) is sealed. Execution: next session
+(`agents/handover-prompts/HANDOFF-2026-08-14-2.md`).
+
+## Q84: How does the shipped SDK lay out?
+
+**Decision (maintainer, sealed):** vertical feature slices with flat public namespaces —
+client families as folders (`Sessions/`, `Health/`), the pagination spine under
+`Pagination/`, `Models/` and `Internal/` unchanged, the root client and response/exception
+spine at the project root. Public namespaces stay `OpenCode.Sdk` and `OpenCode.Sdk.Models`:
+a namespace is API surface, folders are placement (Stripe/Azure precedent). Test projects
+mirror the layout of the project under test. IDE0130's folder-matches-namespace rule is
+arbitrated for the SDK's public folders per the standing per-rule pattern. Canonical
+wording: `engineering/coding-style.md` §4. Execution: next session.
+
+## Q85: When does the Extensions package rise?
+
+**Decision (maintainer, sealed):** in parallel with M2 breadth instead of waiting for M6 —
+an `AddOpenCode` bring-up (overloads mirroring the three client constructors, root plus
+sub-client registrations so a consumer can resolve `SessionsClient` directly) lands as its
+own batch; the ROADMAP moves "Extensions DI breadth" out of M6 accordingly.
+
+## Review decisions closed with the batch
+
+- **#20 (password semantics)** sealed as recommended and landed (`2be2d0f`): `null` = unset
+ with the environment fallback; empty or whitespace refused with `ArgumentException` — an
+ explicitly blank credential has no upstream meaning; `""` = explicit no-auth stays an
+ additive door if upstream ever ships passwordless serve.
+- **#25 (DynamicProxyGenAssembly2 IVT)** closed keep: the friend grant is the standard
+ NSubstitute mechanism over internal seams and a recorded solved-once decision; internals
+ are not a security boundary. The recorded alternative (hand-written fake replacing the
+ grant) applies only if encapsulation purity is ever preferred.
+
+# Session 21 — 2026-08-14: Alignment batch execution
+
+The three sealed alignment decisions (Q83–Q85) executed on the PR #26 branch: the uniform
+`*Request` rename with its body-plus-query double-derivation refusal pin, the feature-slice
+layout migration with the writer's fail-closed family-folder allowlist, and the Extensions
+bring-up. No sealed decision was reopened; one forward-looking note is recorded.
+
+## Note: generator-emitted DI registrations
+
+`AddOpenCode`'s sub-client registrations are hand-written — one factory line per client
+family. Emitting these registrations from the generator becomes worthwhile when client
+families multiply across breadth batches: the registration list is the same mechanical
+projection the client emitters already own. This is a trigger, not a commitment.
+
+# Session 22 — 2026-08-14: Extensions grill, upstream re-research, location census
+
+The maintainer grilled the Q85 first cut (constructor-mirror DI shape, BYO-HttpClient
+overload, missing IHttpClientFactory, options mutability, env-fallback double-headedness)
+and demanded upstream verification instead of vision-doc relay. Findings re-derived at the
+pinned commit `a6a712a` directly; canonical detail in research doc 15 (§5a, §6). The
+`external/opencode` submodule pointer was found stranded on the 1.x line and moved to the
+spec pin with a lockstep rule in `spec/SNAPSHOT.md`.
+
+## Q87: Is `x-opencode-directory` dead on v2?
+
+**Found:** no — evolved. The server's location middleware resolves dual-channel:
+`location[…]` deepObject query (61 ops, spec-visible, used exclusively by the first-party
+generated client) OR ambient `x-opencode-directory`/`x-opencode-workspace` headers
+(spec-invisible, middleware-level), precedence query > header. The vision spec's
+header-only `Directory` story is obsolete; its §6 was corrected in place.
+
+## Q88: Can the merged-Request need be foreseen instead of wall-triggered?
+
+**Found:** yes — censusable today: 15 ops mix body + query, and in all 15 the only query
+parameter is `location`; `v2.session.list` is the single flat-field exception. The merged
+marshalling question and the location question are one design.
+
+**Decision (maintainer, sealed):** the location + merged-Request input design is done
+proactively in a short design session that opens M3 planning — not deferred until the
+collision wall fires. Census and mechanism notes live in doc 15 §5a/§6.
+
+## Q89: Where do v2 endpoint and credentials actually come from?
+
+**Found:** no URL environment variable exists; the first-party discovery contract is the
+registration file (doc 15 §6). Auth is optional (`ServerAuth.required` = configured,
+non-empty password). The username is `opencode` at the pin (the pinned server hardcodes
+it); `--username`/`OPENCODE_SERVER_USERNAME` is upstream direction — docs and the desktop
+sidecar — not pinned-server behavior, so the `Username` option is the gateway and
+forward-compatibility seam. Client-side
+password-from-environment resolution lives in the CLI consumer
+(`OPENCODE_PASSWORD` → legacy `OPENCODE_SERVER_PASSWORD`), not in the client library.
+Feeds the pending Q86 batch (options shape, env-fallback ownership, `Username` option).
+
+## Q90: How do client construction, options, and DI align with .NET conventions? (the in-session "Q86" batch)
+
+**How researched:** the maintainer's grill of the Q85 first cut plus the Q87–Q89 upstream
+findings; ecosystem verification of options immutability (init-only binds via the
+reflection binder; the .NET 8+ config source generator's required/init support is the
+buggy edge — dotnet/runtime #95006/#101984/#90974 — so `required` stays out).
+
+**Decisions (maintainer, sealed):**
+
+- **Construction:** `OpenCodeClient(OpenCodeClientOptions)` + the caller-owned-HttpClient
+ overload; the Uri constructors retire — the endpoint has one home and the
+ endpoint-must-stay-unset guard disappears. One way to build a client.
+- **Options:** the .NET convention holds — a settable class with the `Action<>` configure
+ pattern — and immutability is expressed at consumption: the public read-only
+ `IOpenCodeClientOptions` view, snapshotted by the pipeline at construction (pinned by
+ test: post-construction mutation never reaches a built client).
+- **Credentials:** `Username` joins the options (upstream's `--username` /
+ `OPENCODE_SERVER_USERNAME`, default `opencode`; the pipeline's hardcode retired);
+ `Password` stays optional — null sends anonymous requests, matching v2's optional auth;
+ blank refusal stays. The environment fallback left the SDK (upstream layering: the
+ consumer resolves env; the CLI's own chain is `OPENCODE_PASSWORD` →
+ `OPENCODE_SERVER_PASSWORD`) — this revises the env half of the #20 decision on Q89
+ evidence; the blank-refusal half stands.
+- **Extensions:** rebuilt on `IHttpClientFactory` — `AddOpenCode(Action<...>)` +
+ AOT-annotated `AddOpenCode(IConfiguration)` bind options, register a transient typed
+ client over a named factory client, register sub-clients explicitly, and return the
+ `IHttpClientBuilder` so consumer handlers/resilience/telemetry chain on without SDK
+ middleware. The BYO-HttpClient registration overload is gone (a captured instance
+ defeats factory rotation; no surveyed companion package ships one).
+- **Transport:** the self-created path uses `SocketsHttpHandler` with
+ `PooledConnectionLifetime` on modern TFMs (vision §7.5's promise, implemented); the
+ net472 `ServicePointManager` half stays an M3 item.
+- The sandbox is the standing DI showcase (Generic Host, builder-chained consumer
+ handler, direct sub-client resolution).
+
+# Session 23 — 2026-08-15: Q91 BYO-transport seal
+
+## Q91: Does the caller-owned `(HttpClient, options)` constructor stay public?
+
+**How researched:** seven-agent primary-source survey (2026-08-14, versions pinned at
+release tags) across Azure.Core/System.ClientModel, OpenAI, AWS v4, Stripe.net, Octokit,
+the typed-client ecosystem (Refit, Kiota, Grpc, Elastic, MCP), and the
+BCL/IHttpClientFactory layer — census, findings, and sources in research doc 16; upstream
+v2 JS client read at the pin (`packages/client`: public `fetch` option, SDK-wins header
+precedence encoded in the generated client).
+
+**Found:** no surveyed SDK gates transport injection behind a DI companion (AWS v4
+removed the knob from its DI options type); every SDK resolves header conflicts by the
+BCL's silent request-wins merge with zero guards, and every surveyed anonymous mode leaks
+a caller default `Authorization`; the BCL cannot express "anonymous by omission" over a
+caller client, and the same leak exists on the factory path via `ConfigureHttpClient` —
+so the pipeline guard is required regardless of the constructor's visibility; the stock
+typed-client pattern hard-requires a public `(HttpClient, …)` constructor. The
+internalize+IVT leaning and a handler-seam-on-options alternative were both weighed and
+rejected (doc 16 §4) — the latter on the Q90 bindable/snapshot options contract.
+
+**Decision (maintainer, sealed):** the constructor stays public. Anonymous mode fails
+closed — `Password == null` while the injected client's `DefaultRequestHeaders` carry
+`Authorization` refuses at construction and before every send (construction-only would
+miss legal post-construction mutation). With `Password` set, the SDK's per-request
+`Authorization` deterministically wins (BCL request-wins merge); the precedence contract
+is documented on the constructor and options. Standalone handler/proxy/TLS composition
+rides the BYO client; concrete convenience knobs remain an additive future. Executes as
+review blocker #1's fix in this batch.
diff --git a/docs/research/15-opencode-v2-platform.md b/docs/research/15-opencode-v2-platform.md
index 2f5e1f6a..78facee4 100644
--- a/docs/research/15-opencode-v2-platform.md
+++ b/docs/research/15-opencode-v2-platform.md
@@ -1,17 +1,19 @@
# The opencode v2 platform: branch, surface, architecture, distribution
-Date: 2026-08-13
+Date: 2026-08-14
> Retarget-decision research (docs 09/10 follow-up). Question: what is the opencode v2 platform
> today — which branch carries it, what API surface it serves, how its server/client architecture
-> and its distribution work, and what retargeting this SDK onto it changes. Sources, all retrieved
+> and its distribution work, and what retargeting this SDK onto it changes. Sources, retrieved
> 2026-08-13: `anomalyco/opencode` branch `v2` at head `1288161` ("chore: generate"), inspected
> via shallow clone; the GitHub REST API (branch head, commit list, releases, default branch); the
> npm registry (`@opencode-ai/cli` and `opencode-ai` dist-tags); live probes of
> `update.opencode.ai`; and a live install and run of `@opencode-ai/cli@next` (0.0.0-next-17403)
-> on this machine. This document is the canonical current-state picture of the v2 platform; docs
-> 09 and 10 remain the dated v1-line genealogy it extends, and research log session 12 (Q58)
-> recorded the branch's first sighting.
+> on this machine. §5a and the auth detail in §6 were re-derived 2026-08-14 against the pinned
+> spec commit `a6a712a` directly (submodule checkout, `git grep`/`git show` at the pin). This
+> document is the canonical current-state picture of the v2 platform; docs 09 and 10 remain the
+> dated v1-line genealogy it extends, and research log session 12 (Q58) recorded the branch's
+> first sighting.
## 1. The active branch is `v2`, and the investment is visibly there
@@ -115,6 +117,27 @@ mangling rule covers them. Two-day drift against session 12's census of the same
(2026-08-11: 322 schemas, 422 `allOf`, 359 single-value enums) measures the churn rate directly
— the branch moves daily, which is exactly what snapshot pinning is for.
+### 5a. Parameter-placement census at the pin (`a6a712a`, 2026-08-14)
+
+120 operations: 59 GET, 44 POST, 12 DELETE, 3 PUT, 2 PATCH. Placement facts that bound the
+SDK's request-marshalling design:
+
+- **Header parameters: zero.** No operation declares an `in: header` parameter; the
+ ambient location headers (§6) are middleware-level and spec-invisible.
+- **`location` deepObject: 61 operations** carry an optional `location` query parameter
+ (`style: deepObject`, `explode: true` — wire shape `location[directory]=…&location[workspace]=…`,
+ schema `anyOf[{directory?, workspace?}, null]`).
+- **Body + query mixing: 15 operations** (POST/PUT/PATCH/DELETE) — and in every one of them
+ the *only* query parameter is `location`. The merged-Request marshalling question and the
+ location question are the same design.
+- **Flat location fields: exactly one operation** — `v2.session.list` carries
+ `directory`/`workspace`/`project`/`subpath` as plain query parameters instead of the
+ deepObject; the platform-wide mechanism is the deepObject.
+
+Side observation validating the Q83 rename: the v2 first-party generated client emits one
+uniform `{Op}Input` type per operation (`SessionListInput`, `SessionPromptInput`, …) —
+upstream's own rendering of the uniform-request idea.
+
**Streaming reshape.** `text/event-stream` lives on `v2.event.subscribe` and `v2.session.log`;
`v2.pty.connect` carries `x-websocket`; `x-effect-stream` rides both SSE operations
(2026-08-13 ingestion-level correction of this session's earlier jq census). The v1 durable
@@ -162,10 +185,28 @@ dies, server exits. It scrubs `OPENCODE_PASSWORD`/`OPENCODE_SERVER_PASSWORD` fro
environment and generates a random password. Made for embedding hosts (the desktop uses the
bundled CLI this way).
-**Auth is unchanged from v1**: HTTP Basic, username `opencode`, optional password; service mode
-generates `randomBytes(32).toString("base64url")` when unconfigured; foreground `serve` prints
-the password when it generated one. The SDK's decoration decisions carry over verbatim; the
-`location[…]`-vs-`x-opencode-directory` addressing question is a retarget-time decision.
+**Auth (re-derived at the pin, 2026-08-14)**: HTTP Basic, and **optional** — the server's
+`ServerAuth.required` is true only when a password is configured and non-empty; otherwise the
+authorization middleware is a pass-through (`packages/server/src/middleware/authorization.ts`).
+The username is `opencode` at the pin: the pinned server layer hardcodes it, while the
+`--username`/`-u` and `OPENCODE_SERVER_USERNAME` controls exist only as upstream direction
+(CLI/server docs; the desktop WSL sidecar exports the variable) that has not reached the
+pinned server implementation. Client-side password resolution lives in the *consumer*, not the client library:
+`packages/cli/src/env.ts` resolves `OPENCODE_PASSWORD` falling back to the legacy
+`OPENCODE_SERVER_PASSWORD` and hands the value to the client — the generated client itself
+reads no environment. Service mode generates `randomBytes(32).toString("base64url")` when
+unconfigured; foreground `serve` prints the password when it generated one.
+
+**Location addressing is dual-channel (re-derived at the pin, 2026-08-14)**: the server's
+location middleware (`packages/server/src/location.ts`) resolves per request as
+`location[workspace]` query **or** `x-opencode-workspace` header, and `location[directory]`
+query **or** `x-opencode-directory` header **or** the server's cwd — precedence query >
+header. The per-operation `location` deepObject query parameter (61 operations, §5a) is the
+spec-visible channel the first-party generated client uses exclusively; the headers are the
+spec-invisible ambient channel other consumers (drive driver, CLI) ride. The SDK-side
+rendering — per-op request property vs ambient client/request-option default, plus the
+deepObject marshalling and the `session.list` flat-field exception — is the location +
+merged-Request design session that opens M3 planning (research log Session 22).
## 7. MCP is client-side only; the exposure protocol they chose is ACP
@@ -245,8 +286,10 @@ lifecycle-leash bonus; connect-or-launch fits the discovery-file model).
**Changes at retarget:** the ingestion wall needs one new admit rule (single-element,
validation-only `allOf` unwrap); `HealthResponse` maps `ServiceHealth`; the
-`location[…]`-query vs `x-opencode-directory`-header decoration policy must be decided; the M3
-durable-stream design is re-derived against `session.log` (`after` + `follow`).
+`location[…]`-query vs ambient-header decoration policy is settled as a design input (§6:
+dual-channel, query > header) and its SDK rendering is designed in the location +
+merged-Request session at M3 planning; the M3 durable-stream design is re-derived against
+`session.log` (`after` + `follow`).
## UNVERIFIED / open
diff --git a/docs/research/16-byo-transport-survey.md b/docs/research/16-byo-transport-survey.md
new file mode 100644
index 00000000..0214efaa
--- /dev/null
+++ b/docs/research/16-byo-transport-survey.md
@@ -0,0 +1,101 @@
+# Caller-supplied transport: BYO-HttpClient precedent survey
+
+Date: 2026-08-14
+
+> Research snapshot, 2026-08-14. Primary sources only, retrieved fresh at pinned release
+> tags: Azure SDK for .NET source (Azure.Core 1.61.0, System.ClientModel 1.15.0,
+> Microsoft.Extensions.Azure 1.14.0), openai/openai-dotnet 2.13.0, aws/aws-sdk-net v4
+> (AWSSDK.Core 4.0.101), stripe/stripe-dotnet 52.3.0, octokit/octokit.net 14.0.0,
+> Refit 15.0.0, Microsoft.Kiota.Http.HttpClientLibrary 2.0.0, Grpc.Net.Client 2.83.0,
+> Elastic.Clients.Elasticsearch 9.5.0, ModelContextProtocol.Core 2.2.0, dotnet/runtime
+> v10.0.11, learn.microsoft.com, nuget.org. Informs Q91 (research log Session 23); the
+> maintainer sealed the decision — §§5–6 record analysis, not law.
+
+## 1. Question and stakes
+
+Review blocker #1 exposed the public `(HttpClient, options)` constructor's default-header
+ambiguity: with v2's optional auth, `Password == null` writes no `Authorization`, and the
+BCL then copies a caller client's default `Authorization` onto the nominally anonymous
+request. Q91 asked whether the constructor stays public (with a first-class resolution of
+the ambiguity) or transport injection narrows to the DI companion, with no-DI needs met by
+concrete option knobs.
+
+## 2. Census
+
+| SDK | Caller-supplied transport | DI-gated? | Header-conflict guard | No-DI proxy/TLS |
+|---|---|---|---|---|
+| Azure.Core 1.61 / SCM 1.15 | Public: `options.Transport = new HttpClientTransport(httpClient)` (options property + wrapper; never a client ctor) | No — the DI companion has zero transport API | None; BCL merge decides silently | Pass-through is the documented proxy recipe; Azure.Core adds concrete TLS knobs (`HttpPipelineTransportOptions`) |
+| OpenAI 2.13 | Public: inherited SCM `Transport` property; `HttpClient` absent from its public API | No | None; per-request `Set` wins silently | Pass-through only (README mTLS recipe); zero knobs by in-source design comment |
+| AWS v4 (Core 4.0.101) | Public: `ClientConfig.HttpClientFactory` abstraction (factory, never a raw instance); absent on net472 | No — v4 removed the knob from its DI options type (issue aws-sdk-net#3790) | None; SigV4 per-request wins | Concrete-knobs-first (ProxyHost/Port/Credentials…), factory escape hatch; zero TLS knobs |
+| Stripe.net 52.3 | Public: ctor/options param carrying a wrapper (`new SystemNetHttpClient(httpClient)`) | No — no DI package exists | None; in-source comment claims headers per-message "even when a custom HTTP client is used" | Pass-through only via the wrapper |
+| Octokit 14.0 | Public: `IHttpClient` abstraction + `Func`; a live `HttpClient` cannot be passed | No — no DI package | Structurally unrepresentable in the stock path | Handler factory; proxy ergonomics admittedly poor |
+| Refit 15 / Kiota 2.0 / MCP 2.2 | Public raw `HttpClient` (method param / optional ctor param / ctor param + `ownsHttpClient` flag) | No | None | Pass-through (Kiota adds one `IWebProxy` convenience) |
+| Grpc.Net.Client 2.83 | Public: `GrpcChannelOptions.HttpClient` / `HttpHandler` | No | Transport-shape guards only (throws when both are set; refuses credentials over insecure channels) | `HttpHandler` pass-through |
+| Elastic 9.5 | No raw `HttpClient` anywhere — transport abstraction + full concrete knob set | No (architectural, not packaging) | N/A structurally; explicit in-source precedence rule | The lone full-knobs design; pays with an internal client factory keyed on config hashes |
+| opencode JS v2 (our pin, `packages/client`) | Public `fetch` option on the generated promise client | n/a | Code-defined: SDK/per-request headers overwrite client-level `headers` | n/a — a custom `fetch` is stateless; the ambient-header hazard does not exist in JS |
+
+## 3. Load-bearing findings
+
+1. **DI-gating transport injection has zero precedent.** No surveyed SDK restricts
+ transport to a DI companion; Azure's companion composes over the public core knob and
+ owns nothing; AWS v4 deliberately deleted `HttpClientFactory` from its DI options type
+ and pointed users back at core. Internalize-with-IVT would be an ecosystem-first.
+2. **Nobody guards the header conflict, and the anonymous leak is ecosystem-universal.**
+ dotnet/runtime v10.0.11: default headers are copied onto a request only for names the
+ request does not already set (`HttpHeaders.AddHeaders` — "we don't try to merge").
+ Every SDK sets auth per-request, so caller default `Authorization` is inert on
+ authenticated calls; in every surveyed anonymous mode (Azure credential-less clients,
+ AWS `AnonymousAWSCredentials`, Kiota's anonymous provider, Refit) the caller default
+ flows onto the wire, undocumented and unguarded.
+3. **"Anonymous by omission" is impossible over a caller client.** The BCL has no
+ suppress-default-header mechanism; a request lacking `Authorization` inherits the
+ client default. An SDK with optional auth must refuse the conflict or document the
+ leak — and the same leak exists on the DI path (`ConfigureHttpClient` can set default
+ headers on the factory client), so the Pipeline guard is required regardless of where
+ Q91 lands.
+4. **The stock typed-client pattern hard-requires a public `(HttpClient, …)` ctor.**
+ `AddHttpClient()` resolves constructors via `ActivatorUtilities` →
+ `Type.GetConstructors()` (public only); an internal ctor fails at first
+ `CreateClient` with `InvalidOperationException`. The sanctioned route to an internal
+ ctor is `AddTypedClient(Func)` — usable by our own Extensions,
+ invisible to consumers' stock registrations.
+
+## 4. Alternatives weighed
+
+- **Internalize + IVT to Extensions** (the pre-survey leaning): rejected on findings 1,
+ 3, and 4 — precedent-free gating, safety not actually gained (the guard is needed for
+ the factory path anyway), stock `AddHttpClient()` forecloses, and the
+ standalone handler/proxy/TLS door closes with no knob replacement shipped.
+- **Handler seam on options** (SDK always owns the client, consumers hand
+ `DelegatingHandler`s — the Octokit/Kiota family): rejected on the Q90 options
+ contract — `OpenCodeClientOptions` is bindable data snapshotted once at construction,
+ and a live handler graph breaks binding, snapshotting, and the `IOptions<>` flow.
+ (Honest note: ASP.NET Core itself puts handlers on options-pattern types —
+ `JwtBearerOptions.BackchannelHttpHandler` — so the shape exists; it conflicts with our
+ own sealed options semantics, not with the ecosystem.) A ctor-parameter variant would
+ keep options pure but still break the stock typed-client pattern.
+- **Transport-wrapper options property** (Azure/OpenAI shape): rejected — it pays off
+ only atop a pipeline/transport abstraction this SDK deliberately does not have; for us
+ it is the same raw `HttpClient` with the same hazard and more public surface.
+
+## 5. Decision (sealed — research log Session 23)
+
+The `(HttpClient, options)` constructor stays public. Anonymous mode fails closed:
+`Password == null` while the injected client's `DefaultRequestHeaders` carry
+`Authorization` is refused at construction and before every send (construction-only would
+miss legal post-construction mutation of default headers). With `Password` set, the SDK's
+per-request `Authorization` deterministically wins (BCL request-wins merge) and the
+precedence contract is documented on the constructor and options. Standalone
+handler/proxy/TLS composition rides the BYO client; concrete convenience knobs may be
+added additively if demand appears. Executes as review blocker #1's fix.
+
+## 6. Sources
+
+- Azure: [ClientOptions.cs](https://github.com/Azure/azure-sdk-for-net/blob/3830815f87881cce7af68dd9dd4126cbd90e197b/sdk/core/Azure.Core/src/ClientOptions.cs), [HttpClientTransport.cs](https://github.com/Azure/azure-sdk-for-net/blob/3830815f87881cce7af68dd9dd4126cbd90e197b/sdk/core/Azure.Core/src/Pipeline/HttpClientTransport.cs), [HttpClientTransport.Request.cs](https://github.com/Azure/azure-sdk-for-net/blob/3830815f87881cce7af68dd9dd4126cbd90e197b/sdk/core/Azure.Core/src/Pipeline/HttpClientTransport.Request.cs); [Microsoft.Extensions.Azure on nuget.org](https://www.nuget.org/packages/Microsoft.Extensions.Azure)
+- OpenAI: [OpenAIClient.cs](https://github.com/openai/openai-dotnet/blob/0aafb4c006c69db476607940b10c26fc07de8607/OpenAI/src/Custom/OpenAIClient.cs), [OpenAIClientOptions.cs](https://github.com/openai/openai-dotnet/blob/0aafb4c006c69db476607940b10c26fc07de8607/OpenAI/src/Custom/OpenAIClientOptions.cs), [OpenAIClientUtilities.cs](https://github.com/openai/openai-dotnet/blob/0aafb4c006c69db476607940b10c26fc07de8607/OpenAI/src/Utility/OpenAIClientUtilities.cs), [README](https://github.com/openai/openai-dotnet/blob/0aafb4c006c69db476607940b10c26fc07de8607/README.md)
+- AWS: [ClientConfig.cs (netstandard)](https://github.com/aws/aws-sdk-net/blob/36107286c1ac8dc5dee970e5b34584a7046e6231/sdk/src/Core/Amazon.Runtime/_netstandard/ClientConfig.cs), [HttpRequestMessageFactory.cs](https://github.com/aws/aws-sdk-net/blob/36107286c1ac8dc5dee970e5b34584a7046e6231/sdk/src/Core/Amazon.Runtime/Pipeline/HttpHandler/_netstandard/HttpRequestMessageFactory.cs), [AWSConfigs.netstandard.cs](https://github.com/aws/aws-sdk-net/blob/36107286c1ac8dc5dee970e5b34584a7046e6231/sdk/src/Core/_netstandard/AWSConfigs.netstandard.cs)
+- Stripe: [StripeClient.cs](https://github.com/stripe/stripe-dotnet/blob/19fb69ecd290b671b64a18c9008152bdc882cc5b/src/Stripe.net/Infrastructure/Public/StripeClient.cs), [SystemNetHttpClient.cs](https://github.com/stripe/stripe-dotnet/blob/19fb69ecd290b671b64a18c9008152bdc882cc5b/src/Stripe.net/Infrastructure/Public/SystemNetHttpClient.cs)
+- Octokit: [Connection.cs](https://github.com/octokit/octokit.net/blob/7fa5b0fe4a18c9b981b21290c3ca9320b2d6415b/Octokit/Http/Connection.cs), [HttpClientAdapter.cs](https://github.com/octokit/octokit.net/blob/7fa5b0fe4a18c9b981b21290c3ca9320b2d6415b/Octokit/Http/HttpClientAdapter.cs), [HttpMessageHandlerFactory.cs](https://github.com/octokit/octokit.net/blob/7fa5b0fe4a18c9b981b21290c3ca9320b2d6415b/Octokit/Http/HttpMessageHandlerFactory.cs)
+- Typed-client ecosystem: [RestService.cs](https://github.com/reactiveui/refit/blob/74cbb64d51b705fb52a7bc0e7bd5f4e4b62bfb5e/src/Refit/RestService.cs), [HttpClientRequestAdapter.cs](https://github.com/microsoft/kiota-dotnet/blob/2aa504cc9b566ab92f93a4f1a3a2ba2c5048a9a4/src/http/httpClient/HttpClientRequestAdapter.cs), [KiotaClientFactory.cs](https://github.com/microsoft/kiota-dotnet/blob/2aa504cc9b566ab92f93a4f1a3a2ba2c5048a9a4/src/http/httpClient/KiotaClientFactory.cs)
+- BCL/guidance: [HttpClient.cs L741-779](https://github.com/dotnet/runtime/blob/v10.0.11/src/libraries/System.Net.Http/src/System/Net/Http/HttpClient.cs#L741-L779), [HttpHeaders.cs L617-665](https://github.com/dotnet/runtime/blob/v10.0.11/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HttpHeaders.cs#L617-L665), [DefaultTypedHttpClientFactory.cs](https://github.com/dotnet/runtime/blob/v10.0.11/src/libraries/Microsoft.Extensions.Http/src/DefaultTypedHttpClientFactory.cs), [ActivatorUtilities.cs L679-711](https://github.com/dotnet/runtime/blob/v10.0.11/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/ActivatorUtilities.cs#L679-L711), [HttpClient guidelines](https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines)
+- Upstream parity: `external/opencode` at the pin — `packages/client/src/promise/generated/client.ts` (`baseUrl` + optional `fetch` + `headers`; merge order at lines 265–269)
diff --git a/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md b/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md
new file mode 100644
index 00000000..dea5030c
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md
@@ -0,0 +1,101 @@
+# M2 First Breadth Batch Execution Plan
+
+Date: 2026-08-14
+
+**Goal:** make `v2.session.list`, `v2.session.get`, `v2.session.create`, and
+`v2.message.list` callable through their final generated SDK surface. This batch
+deliberately opens the three machines M1 walled off: query-parameter binding, request
+bodies (the first non-GET verb), and list envelopes with cursor pagination.
+
+**Architecture:** unchanged from Arc B — SpecIR plus curation bind into plans, generic
+name-blind emitters render them, generated methods delegate once into the hand-written
+`Pipeline`. The `Pipeline` grows exactly one capability: sending a JSON request body
+serialized through the source-generated context.
+
+## What the pinned spec says (recon, 2026-08-14)
+
+- `session.list` — GET, **9 optional query parameters**, all `anyOf [T, null]`:
+ `limit` (string-typed in the spec), `order` (`asc`/`desc` enum), `search`, `cursor`
+ (opaque), `parentID` (with a literal `"null"` string variant meaning root-only), and the
+ location-scoping family `workspace`/`directory`/`project`/`subpath` (flat params — the
+ deferred `location[...]` question resolves to these). 400 is a **multi-tag anyOf**
+ (`InvalidCursorError` | `InvalidRequestError1` | `InvalidRequestError`).
+- `session.create` — POST, inline request-body object (all-optional nullable properties:
+ `id`, `title`, `agent`, …), responds `{data: Session.Info}`.
+- `session.get` — GET path param, `{data: Session.Info}`, 404 `SessionNotFoundError`.
+- `message.list` — GET path + `limit`/`order`/`cursor`, responds the same envelope shape,
+ and declares **500 `UnknownError`** — the first 5xx in a status map.
+- Both list responses share one envelope: `{data: [...], cursor: {previous?, next?}}` —
+ one mechanical list-envelope shape covers both.
+- New reachable closure: the `Session.Info` graph (13 properties, nested
+ time/tokens/location/revert) plus `InvalidCursorError`, `UnknownError`, and the
+ dotted-dup name `InvalidRequestError1` (naming resolution must stay mechanical or fail
+ closed into a curated override).
+
+## Boundaries
+
+- Arc B boundaries carry over verbatim: opencode-dialect-only, fail-closed on any
+ unsupported selected shape, name-blind emitters, curation-driven public API, mechanical
+ payload names with collision-only overrides, optionality/nullability independence,
+ non-2xx stays on the API-error channel (now including 500), undeclared 2xx is a
+ protocol failure.
+- The Arc B stop-condition list carries unchanged: binder needing raw OpenAPI,
+ name-branching emitters, structural unions, ownership ambiguity, TFM breakage, partial
+ packs.
+- **Spec pin stays at `a6a712a`** for this batch — the pin is one day old; refreshing
+ buys nothing but churn. Next sanctioned refresh moment: M3 boundary.
+- Cursor **pagination surface stays raw** in this batch: response envelopes expose the
+ wire cursor (`Previous`/`Next`), request options accept an opaque `cursor` string. A
+ paginator/`IAsyncEnumerable` convenience is an M3 candidate, designed together with the
+ stream surface — not here.
+
+## Public-API decisions (maintainer, sealed 2026-08-14)
+
+1. **Query surface:** generated per-operation options records deriving from one shared
+ `ListOptions` base that carries only the cursor-pagination trio (`Limit`, `Order`,
+ `Cursor`). The generator owns a fail-closed **profile-detection wall**: an operation
+ derives from the base only when its wire parameters match the profile exactly;
+ otherwise it gets flat standalone options. The base is the typed seam the M3 paginator
+ consumes. Dichotomy: `*Options` shapes the call (query), `*Request` is the wire body.
+2. **`limit` is `int?`** — invariant conversion to the wire string at the route boundary;
+ non-positive values refused with `ArgumentException`. `uint` rejected: FDG bans
+ unsigned in public APIs (CLS), zero ecosystem precedent, and it does not buy the
+ invariant (0 stays representable).
+3. **`parentID` rides `SessionParentFilter`** — a small hand-written public spine type
+ (`RootOnly` singleton, `Of(id)` factory); the binder recognizes the wire shape
+ mechanically (`anyOf` of a patterned string and the literal `"null"` enum), never the
+ parameter name. Invalid states are unrepresentable; no magic strings.
+4. **Create body model is `SessionCreateRequest`** with an optional parameter —
+ `CreateSessionAsync()` sends an empty body (every property is optional on the wire).
+5. **`List*`/`Create*` verb rules** join `OperationNamePolicy`, including the C18
+ structural-verb-position fix (#22).
+
+## Tasks
+
+- [x] **0. Decision batch** — the five decisions above, sealed with the maintainer
+ 2026-08-14 (research log Session 19).
+- [x] **1. Curation + binding** — curation rows for the four operations; binder support
+ for optional nullable query parameters (fail-closed on any other query shape) and for
+ inline JSON request bodies bound into a generated request model; multi-tag 400 and the
+ 500 arm enter the status-map machinery.
+- [x] **2. Model closure** — regenerate with the `Session.Info` graph, the new error
+ types, and the list envelopes; resolve `InvalidRequestError1` naming mechanically or
+ fail closed into a curated override; API baseline review.
+- [x] **3. Pipeline body path** — `ExecuteAsync` overload (or body-carrying request
+ shape) serializing through `OpenCodeJsonContext`; `Content-Type: application/json`;
+ covered by pipeline tests (body bytes, header, GET stays body-less).
+- [x] **4. Emitters** — operation methods carrying options records and body models; route
+ builders composing escaped query strings; list-envelope response adapters; the
+ generator fail-closed walls batch (#21) lands here, where breadth first stresses those
+ walls, with P2's single-pass `{data}` DTO folded into the envelope design.
+- [x] **5. Naming batch (#22)** — `List*`/`Create*` rules + C18 fix; verify C17/C19/C20
+ against the wall and fix what holds.
+- [x] **6. Hardening riders** — F07 concrete `Unknown*` converter (#19); decisions #20
+ (password semantics) and #25 (IVT) to the maintainer alongside task 0.
+- [x] **7. Contract matrix + perf** — contract tests over the four operations (success,
+ every declared error status, cursor round-trip, undeclared-2xx, empty-marker); extend
+ `ClientOperationBenchmarks` with a `ListMessagesAsync` benchmark once the surface
+ exists.
+- [x] **8. Close-out** — live sandbox demo (create → list → get → messages) against
+ `opencode2 serve`; ROADMAP status update; research-log entries for decisions sealed
+ during execution.
diff --git a/docs/superpowers/specs/2026-08-09-public-api-design.md b/docs/superpowers/specs/2026-08-09-public-api-design.md
index 044f8371..698ae611 100644
--- a/docs/superpowers/specs/2026-08-09-public-api-design.md
+++ b/docs/superpowers/specs/2026-08-09-public-api-design.md
@@ -296,12 +296,13 @@ public sealed class OpenCodeRequestOptions
demonstrates the need.
- `CancellationToken` stays a **separate last parameter** (TAP convention — deliberately
unlike Azure's RequestContext, which folds it in).
-- `Directory` resolves the ROADMAP question: client-level default (§10) + per-call
- override, the .NET rendering of upstream's `createOpencodeClient({directory})` +
- per-request header. Carried as the `x-opencode-directory` header on every request,
- SSE included (header-only; upstream's GET/HEAD query rewrite is a browser/EventSource
- constraint we don't share). Server precedence is query > header, so explicit per-op
- `location[…]` parameters override the ambient value — documented behavior.
+- `Directory` as a concept — client-level ambient default (§10) + per-call override —
+ survives, but the v1 header-only transport described here is obsolete: v2 resolves
+ location dual-channel (`location[…]` deepObject query parameters, used exclusively by
+ the first-party client, OR ambient `x-opencode-directory`/`x-opencode-workspace`
+ headers; precedence query > header — research doc 15 §6). The concrete SDK rendering
+ is designed in the location + merged-Request session at M3 planning (research log
+ Session 22).
- The options class grows extend-only (adding properties is non-breaking).
## 7. Client composition
diff --git a/external/opencode b/external/opencode
index d7b115f6..a6a712a3 160000
--- a/external/opencode
+++ b/external/opencode
@@ -1 +1 @@
-Subproject commit d7b115f623760e68a4749d16508a9eca350f246f
+Subproject commit a6a712a3ac72248c9b2f2f883e752e6e18ef8c40
diff --git a/spec/SNAPSHOT.md b/spec/SNAPSHOT.md
index cf497e93..6f6ecd82 100644
--- a/spec/SNAPSHOT.md
+++ b/spec/SNAPSHOT.md
@@ -21,8 +21,12 @@ Platform evidence for the v2 line: `docs/research/15-opencode-v2-platform.md`.
that commit (read-only `git fetch` in the `external/opencode` submodule, or
`raw.githubusercontent.com` pinned to the full commit SHA — never a branch ref).
2. Copy it over `spec/openapi.json`.
-3. Update the table above (commit, channel) and the `Date:` line.
-4. Run `generate`; resolve what it reports (wall admits, curation rows) and review the
+3. Move the `external/opencode` submodule pointer to the same commit in the same change
+ (`git -C external/opencode fetch origin v2 && git -C external/opencode checkout
+ `, then stage the gitlink) — the submodule checkout and this snapshot never
+ diverge, so a fresh `git submodule update --init` always lands on the pinned commit.
+4. Update the table above (commit, channel) and the `Date:` line.
+5. Run `generate`; resolve what it reports (wall admits, curation rows) and review the
regenerated diff.
A dedicated spec-refresh tool is planned — see repo tooling in `docs/ROADMAP.md`.
diff --git a/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj b/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj
index a1747db8..272f450f 100644
--- a/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj
+++ b/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj
@@ -7,8 +7,24 @@
true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs
new file mode 100644
index 00000000..c74dd3f0
--- /dev/null
+++ b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs
@@ -0,0 +1,56 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using OpenCode.Sdk;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// Registers the opencode client family over IHttpClientFactory: a typed
+/// riding factory-managed transport, plus every sub-client
+/// resolved from it so a consumer can inject directly. The
+/// returned is the composition seam — chain
+/// AddHttpMessageHandler, resilience handlers, or telemetry instrumentation onto it.
+///
+public static class OpenCodeServiceCollectionExtensions
+{
+ /// Registers the opencode client, shaping its options in code.
+ /// The service collection to register with.
+ /// Shapes the client options; the endpoint is required.
+ /// The typed-client builder for transport composition.
+ public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ _ = services.Configure(configure);
+ return AddOpenCodeCore(services);
+ }
+
+ /// Registers the opencode client, binding its options from configuration.
+ /// The service collection to register with.
+ /// The configuration section carrying the client options.
+ /// The typed-client builder for transport composition.
+ [RequiresDynamicCode("Configuration binding uses reflection over the options type; prefer the configure-action overload on native AOT.")]
+ [RequiresUnreferencedCode("Configuration binding may require members trimming removes; prefer the configure-action overload when trimming.")]
+ public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ _ = services.AddOptions().Bind(configuration);
+ return AddOpenCodeCore(services);
+ }
+
+ private static IHttpClientBuilder AddOpenCodeCore(IServiceCollection services)
+ {
+ // A named factory client bound as the typed client: the factory owns the HttpClient
+ // and its handler rotation, so the client registers transient and disposes nothing.
+ var builder = services.AddHttpClient(nameof(OpenCodeClient))
+ .AddTypedClient(static (httpClient, provider) => new OpenCodeClient(
+ httpClient,
+ provider.GetRequiredService>().Value));
+ _ = services.AddTransient(static SessionsClient (provider) => provider.GetRequiredService().Sessions);
+ return builder;
+ }
+}
diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json
index e68c919a..f76aae98 100644
--- a/src/OpenCode.Sdk/.generated-manifest.json
+++ b/src/OpenCode.Sdk/.generated-manifest.json
@@ -1,17 +1,38 @@
{
"files": [
- "HealthResponse.cs",
+ "Health/HealthResponse.cs",
"Internal/ResponseAdapters/HealthResponseAdapter.cs",
+ "Internal/ResponseAdapters/MessageListResponseAdapter.cs",
+ "Internal/ResponseAdapters/SessionCreateResponseAdapter.cs",
+ "Internal/ResponseAdapters/SessionListResponseAdapter.cs",
"Internal/ResponseAdapters/SessionMessageResponseAdapter.cs",
+ "Internal/ResponseAdapters/SessionResponseAdapter.cs",
+ "Internal/Serialization/MessageListResponseEnvelope.cs",
"Internal/Serialization/OpenCodeErrorJsonConverter.cs",
"Internal/Serialization/OpenCodeJsonContext.cs",
"Internal/Serialization/OptionalCollectionInput.cs",
"Internal/Serialization/PromptFileSourceJsonConverter.cs",
+ "Internal/Serialization/SessionCreateResponseEnvelope.cs",
+ "Internal/Serialization/SessionForkBoundaryJsonConverter.cs",
+ "Internal/Serialization/SessionListResponseEnvelope.cs",
"Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs",
"Internal/Serialization/SessionMessageCompactionJsonConverter.cs",
"Internal/Serialization/SessionMessageInfoJsonConverter.cs",
+ "Internal/Serialization/SessionMessageResponseEnvelope.cs",
"Internal/Serialization/SessionMessageToolStateJsonConverter.cs",
+ "Internal/Serialization/SessionResponseEnvelope.cs",
"Internal/Serialization/ToolContentJsonConverter.cs",
+ "Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs",
+ "Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs",
+ "Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs",
+ "Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs",
+ "Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs",
+ "Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs",
+ "Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs",
+ "Internal/Serialization/UnknownToolContentJsonConverter.cs",
+ "Models/FileDiffInfo.cs",
+ "Models/FileDiffInfoStatus.cs",
+ "Models/InvalidCursorError.cs",
"Models/InvalidRequestError.cs",
"Models/LocationRef.cs",
"Models/MessageNotFoundError.cs",
@@ -25,6 +46,13 @@
"Models/PromptMention.cs",
"Models/PromptSkillAttachment.cs",
"Models/ServiceHealth.cs",
+ "Models/SessionCreateRequest.cs",
+ "Models/SessionForkBoundary.cs",
+ "Models/SessionForkBoundaryBefore.cs",
+ "Models/SessionForkBoundaryThrough.cs",
+ "Models/SessionInfo.cs",
+ "Models/SessionInfoFork.cs",
+ "Models/SessionInfoTime.cs",
"Models/SessionMessageAgentSelected.cs",
"Models/SessionMessageAgentSelectedTime.cs",
"Models/SessionMessageAssistant.cs",
@@ -72,6 +100,7 @@
"Models/SessionMessageUser.cs",
"Models/SessionMessageUserTime.cs",
"Models/SessionNotFoundError.cs",
+ "Models/SessionRevert.cs",
"Models/SessionStructuredError.cs",
"Models/TokenUsageInfo.cs",
"Models/TokenUsageInfoCache.cs",
@@ -79,8 +108,10 @@
"Models/ToolFileContent.cs",
"Models/ToolTextContent.cs",
"Models/UnauthorizedError.cs",
+ "Models/UnknownError.cs",
"Models/UnknownOpenCodeError.cs",
"Models/UnknownPromptFileSource.cs",
+ "Models/UnknownSessionForkBoundary.cs",
"Models/UnknownSessionMessageAssistantContent.cs",
"Models/UnknownSessionMessageCompaction.cs",
"Models/UnknownSessionMessageInfo.cs",
@@ -88,8 +119,14 @@
"Models/UnknownToolContent.cs",
"OpenCodeClient.cs",
"OpenCodeRoutes.cs",
- "SessionClient.cs",
- "SessionMessageResponse.cs",
- "SessionsClient.cs"
+ "Sessions/MessageListRequest.cs",
+ "Sessions/MessageListResponse.cs",
+ "Sessions/SessionClient.cs",
+ "Sessions/SessionCreateResponse.cs",
+ "Sessions/SessionListRequest.cs",
+ "Sessions/SessionListResponse.cs",
+ "Sessions/SessionMessageResponse.cs",
+ "Sessions/SessionResponse.cs",
+ "Sessions/SessionsClient.cs"
]
}
diff --git a/src/OpenCode.Sdk/.generation-incomplete b/src/OpenCode.Sdk/.generation-incomplete
index a97248a4..a10db037 100644
--- a/src/OpenCode.Sdk/.generation-incomplete
+++ b/src/OpenCode.Sdk/.generation-incomplete
@@ -1,8 +1,12 @@
Generation is incomplete; packages must not be published.
-Selected operations: 2
-Pending operations: 118
+Selected operations: 6
+Pending operations: 114
Selected:
- v2.health.get
+- v2.message.list
+- v2.session.create
+- v2.session.get
+- v2.session.list
- v2.session.message
Pending:
- v2.agent.get
@@ -39,7 +43,6 @@ Pending:
- v2.mcp.list
- v2.mcp.remove
- v2.mcp.resource.catalog
-- v2.message.list
- v2.model.default
- v2.model.list
- v2.permission.request.list
@@ -69,7 +72,6 @@ Pending:
- v2.session.command
- v2.session.compact
- v2.session.context
-- v2.session.create
- v2.session.export
- v2.session.fork
- v2.session.form.cancel
@@ -79,7 +81,6 @@ Pending:
- v2.session.form.reply
- v2.session.form.state
- v2.session.generate
-- v2.session.get
- v2.session.import
- v2.session.inbox.cancel
- v2.session.inbox.list
@@ -89,7 +90,6 @@ Pending:
- v2.session.instructions.entry.put
- v2.session.instructions.entry.remove
- v2.session.interrupt
-- v2.session.list
- v2.session.log
- v2.session.move
- v2.session.permission.create
diff --git a/src/OpenCode.Sdk/HealthResponse.cs b/src/OpenCode.Sdk/Health/HealthResponse.cs
similarity index 100%
rename from src/OpenCode.Sdk/HealthResponse.cs
rename to src/OpenCode.Sdk/Health/HealthResponse.cs
diff --git a/src/OpenCode.Sdk/IOpenCodeClientOptions.cs b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs
new file mode 100644
index 00000000..c00daad3
--- /dev/null
+++ b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs
@@ -0,0 +1,18 @@
+namespace OpenCode.Sdk;
+
+///
+/// The read-only view of the client options. Consumption sites — the pipeline, the DI
+/// composition — read through this contract and snapshot at construction, so an options
+/// instance mutated after a client is built never changes that client's behavior.
+///
+public interface IOpenCodeClientOptions
+{
+ /// Gets the absolute HTTP or HTTPS server endpoint; required to build a client.
+ public Uri? Endpoint { get; }
+
+ /// Gets the HTTP basic-authentication username; the pinned server accepts only the default opencode.
+ public string Username { get; }
+
+ /// Gets the HTTP basic-authentication password; sends anonymous requests.
+ public string? Password { get; }
+}
diff --git a/src/OpenCode.Sdk/Internal/Abstractions/IEnvironmentProvider.cs b/src/OpenCode.Sdk/Internal/Abstractions/IEnvironmentProvider.cs
deleted file mode 100644
index 8996eccd..00000000
--- a/src/OpenCode.Sdk/Internal/Abstractions/IEnvironmentProvider.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace OpenCode.Sdk.Internal.Abstractions;
-
-/// Reads process environment variables behind a substitutable seam.
-internal interface IEnvironmentProvider
-{
- /// Reads one environment variable.
- /// The variable name.
- /// The value, or when the variable is not set.
- public string? GetEnvironmentVariable(string name);
-}
diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs
index 177806a6..a4cbbfed 100644
--- a/src/OpenCode.Sdk/Internal/Pipeline.cs
+++ b/src/OpenCode.Sdk/Internal/Pipeline.cs
@@ -1,82 +1,199 @@
using System.Globalization;
using System.Net.Http.Headers;
using System.Text;
-using OpenCode.Sdk.Internal.Abstractions;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
namespace OpenCode.Sdk.Internal;
/// Owns request decoration, sending, buffering, and error-channel policy for every operation.
internal sealed class Pipeline : IDisposable
{
- private const string BasicUser = "opencode";
- private const string PasswordVariable = "OPENCODE_SERVER_PASSWORD";
-
private readonly AuthenticationHeaderValue? _authorization;
private readonly string _endpointBase;
+ private readonly bool _enforceAnonymousDefaults;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly ProductInfoHeaderValue _userAgent;
private bool _disposed;
- internal Pipeline(HttpClient httpClient, bool ownsHttpClient, Uri endpoint, string? password, IEnvironmentProvider environment)
+ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOptions options)
{
ArgumentNullException.ThrowIfNull(httpClient);
- ArgumentNullException.ThrowIfNull(endpoint);
- ArgumentNullException.ThrowIfNull(environment);
+ ArgumentNullException.ThrowIfNull(options);
+ var endpoint = options.Endpoint ?? throw new ArgumentException("OpenCodeClientOptions.Endpoint is required.", nameof(options));
+
+ // Basic credentials ride the wire as "username:password", so a blank name or a colon
+ // inside it would corrupt the header instead of failing a login.
+ var username = options.Username;
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ throw new ArgumentException("The username cannot be empty or whitespace.", nameof(options));
+ }
+
+ if (username.Contains(':', StringComparison.Ordinal))
+ {
+ throw new ArgumentException("The username cannot contain a colon.", nameof(options));
+ }
+
+ // An explicitly blank password has no upstream meaning (a server without configured
+ // authentication expects no credentials at all), so it fails loudly; null is the
+ // anonymous spelling.
+ var password = options.Password;
+ if (password is not null && string.IsNullOrWhiteSpace(password))
+ {
+ throw new ArgumentException(
+ "An explicit password cannot be empty or whitespace; leave it null for a server without authentication.",
+ nameof(options));
+ }
+
+ // The pipeline builds every request URI absolute from Endpoint, so a BaseAddress on
+ // an injected client would never be consulted; refuse the conflict instead of
+ // silently ignoring half the caller's configuration.
+ if (!ownsHttpClient && httpClient.BaseAddress is not null)
+ {
+ throw new ArgumentException(
+ "The supplied HttpClient carries a BaseAddress, which this SDK never consults — requests are " +
+ "built absolute from OpenCodeClientOptions.Endpoint. Remove the BaseAddress or change the Endpoint.",
+ nameof(httpClient));
+ }
+
+ // Anonymous mode cannot be expressed by omission over an injected client: HttpClient
+ // copies its default headers onto every request that lacks the header, so a default
+ // Authorization would ride requests this SDK promises are anonymous. Refuse the
+ // conflict instead of silently sending a foreign credential.
+ if (!ownsHttpClient && password is null && CarriesDefaultAuthorization(httpClient))
+ {
+ throw new ArgumentException(
+ "The options request anonymous requests (Password is null), but the supplied HttpClient's " +
+ "default headers carry an Authorization header. Remove the default header or set a password.",
+ nameof(httpClient));
+ }
_httpClient = httpClient;
_ownsHttpClient = ownsHttpClient;
_endpointBase = EndpointPolicy.Normalize(endpoint);
_userAgent = UserAgentPolicy.Resolve();
- // The environment fallback is read exactly once, here; requests reuse the resolved header.
- var resolvedPassword = string.IsNullOrEmpty(password) ? environment.GetEnvironmentVariable(PasswordVariable) : password;
- _authorization = string.IsNullOrEmpty(resolvedPassword)
+ // The options are read exactly once, here: the pipeline holds an immutable snapshot,
+ // so mutating the options object after construction never changes a built client.
+ _authorization = password is null
? null
- : new AuthenticationHeaderValue(
- "Basic",
- Convert.ToBase64String(Encoding.UTF8.GetBytes($"{BasicUser}:{resolvedPassword}")));
+ : new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")));
+
+ // Default headers stay legally mutable after construction, so the anonymous-mode
+ // refusal must also run per send; owned transports never expose their client and
+ // authenticated sends override the default, so only anonymous injected clients pay.
+ _enforceAnonymousDefaults = !ownsHttpClient && _authorization is null;
}
- public static Pipeline Create(Uri endpoint, OpenCodeClientOptions? options, IEnvironmentProvider environment)
+ public static Pipeline Create(OpenCodeClientOptions options)
{
- ArgumentNullException.ThrowIfNull(endpoint);
- ArgumentNullException.ThrowIfNull(environment);
- if (options?.Endpoint is not null)
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (options.Endpoint is null)
{
- throw new ArgumentException(
- "The endpoint constructor owns endpoint authority; leave OpenCodeClientOptions.Endpoint unset.",
- nameof(options));
+ throw new ArgumentException("OpenCodeClientOptions.Endpoint is required.", nameof(options));
}
// Validate before constructing the owned client so a refused endpoint leaks nothing.
- _ = EndpointPolicy.Normalize(endpoint);
- return new Pipeline(new HttpClient(), ownsHttpClient: true, endpoint, options?.Password, environment);
+ _ = EndpointPolicy.Normalize(options.Endpoint);
+ var httpClient = CreateOwnedHttpClient();
+ try
+ {
+ return new Pipeline(httpClient, ownsHttpClient: true, options);
+ }
+ catch
+ {
+ // Credential validation still throws inside the constructor; the owned
+ // transport must not outlive a refused construction.
+ httpClient.Dispose();
+ throw;
+ }
}
- public static Pipeline Create(HttpClient httpClient, OpenCodeClientOptions options, IEnvironmentProvider environment)
+ public static Pipeline Create(HttpClient httpClient, OpenCodeClientOptions options)
{
ArgumentNullException.ThrowIfNull(httpClient);
ArgumentNullException.ThrowIfNull(options);
- ArgumentNullException.ThrowIfNull(environment);
+
if (options.Endpoint is null)
{
- throw new ArgumentException(
- "A caller-supplied HttpClient requires OpenCodeClientOptions.Endpoint.",
- nameof(options));
+ throw new ArgumentException("A caller-supplied HttpClient requires OpenCodeClientOptions.Endpoint.", nameof(options));
}
- return new Pipeline(httpClient, ownsHttpClient: false, options.Endpoint, options.Password, environment);
+ return new Pipeline(httpClient, ownsHttpClient: false, options);
}
- public async Task ExecuteAsync(HttpMethod method, string route, ResponseAdapter adapter,
+ public Task ExecuteAsync(HttpMethod method, string route, ResponseAdapter adapter,
OpenCodeRequestOptions? options, CancellationToken cancellationToken)
+ where TResponse : OpenCodeResponse =>
+ ExecuteCoreAsync