Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -804,10 +804,10 @@ dotnet_diagnostic.CA1034.severity = none
# 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]
[src/OpenCode.Sdk/{Sessions,Health,Pagination,Shells}/**.cs]
dotnet_diagnostic.IDE0130.severity = none

[tests/OpenCode.Sdk.Tests/{Sessions,Health,Pagination}/**.cs]
[tests/OpenCode.Sdk.Tests/{Sessions,Health,Pagination,Shells}/**.cs]
dotnet_diagnostic.IDE0130.severity = none

# DI extension methods live in the Microsoft.Extensions.DependencyInjection namespace so
Expand Down
29 changes: 24 additions & 5 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,24 @@ by the SDK test project; the Extensions tests are registration-topology only).
Session 24). Q92's simplicity-first construction (ADR-0010) is landed — Arc 1 complete:
the transport constructor is internal friend-assembly test surface, Extensions registers
a factory-less singleton client family with a roster contract test, and the Q91 guard
machinery is deleted — closing #31 by construction. #28, #32, and #33 are sealed with
their execution homes recorded (Session 24), and the location + merged-Request design is
machinery is deleted — closing #31 by construction. #32 and #33 stay sealed with their
execution homes recorded (Session 24), and the location + merged-Request design is
sealed (Session 25, Q93/Q94: the binder placement map and the dual-channel location
rendering) — the body+query admission stop condition is lifted. Remaining M2 breadth
batches interleave next against the sealed design.
rendering).

**The M2 second breadth batch is complete** (plan:
`superpowers/plans/2026-08-15-m2-second-breadth-batch.md`) — the design-prover batch:
`session.remove`/`session.rename` and the `Shells` family
(`list`/`create`/`get`/`remove`/`timeout`) ride the 204 no-content and
`{location, data}` envelope machinery, the deepObject `LocationSelector` query channel,
the merged body+query request models, the ambient options location riding the
middleware headers, and the first PATCH/DELETE verbs. #28 landed as the
`mutuallyExclusiveQueries` curation section with the route-boundary refusal on
`message.list`. Demonstrated live 2026-08-16 against `opencode2 serve`
v0.0.0-next-17403 (shell create → get → timeout → remove and session rename → remove,
204s typed, ambient location echoed). `v2.shell.output` deferred to a later batch —
its inline data object and integer cursor query params each need a mechanism of their
own. 13 operations selected, 107 pending.

## Milestones

Expand Down Expand Up @@ -112,7 +125,13 @@ is revisited at each milestone boundary.
background service's discovery file are candidate mechanisms — decided in the M4 plan;
platform detail: research doc 15.)
5. **M5 — Full surface.** Complete generation profile over the protocol surface,
exclusion fingerprints (ADR-0008), packaging unblocked.
exclusion fingerprints (ADR-0008), packaging unblocked. The **ambient location
header decision (#37)** lands here and is decision-first: packaging unblocking
freezes the public surface, so this is the last free moment to drop
`OpenCodeClientOptions.Location` (option B) or fold it into the query channel
(option C). The batch admitting `project.list` / `permission.saved.*` /
`session.form.*` answers whether the header is those operations' only addressing
channel.
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
Expand Down
67 changes: 67 additions & 0 deletions docs/research/00-research-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2023,3 +2023,70 @@ per-request `Location` wins over the ambient default because the server resolves
before header — the SDK performs no client-side merge. `v2.session.list`'s flat location
fields bind as ordinary query properties with no special case; the fail-closed walls
stay.

# Session 26 — 2026-08-16: Q95 location-channel tour, measured

The maintainer challenged the ambient location header mid-batch ("deprecate olmuştu,
şemada da yok artık"). The claim was investigated against upstream rather than argued,
the batch's encoding defect was found and fixed as a result, and the ambient channel's
future became an M5 decision (#37).

## Q95: Is the `x-opencode-directory` header deprecated, and how does location actually resolve?

**How researched:** three parallel primary-source sweeps over `external/opencode` at the
spec pin (`a6a712a`) — the generated JS SDK, the server/core internals, and every
consumer — plus live probes against `opencode2 serve` v0.0.0-next-17403 started in a
directory distinct from every probe target.

**Found — not deprecated, and never removed from the schema:**

- The submodule sits exactly on the spec pin, and upstream `v2` is 164 commits ahead of
it; at that live HEAD `packages/server/src/location.ts:31,34` still reads both headers,
still `decodeURIComponent`s the directory one, and **no commit has touched the file
since the pin**. No deprecation marker exists anywhere in `packages/server` or
`packages/core`.
- The header was never expressible in the document, so it was never scrubbed from it. The
spec is `OpenApi.fromApi(ClientApi)` (`packages/protocol/script/generate-openapi.ts`), a
16-line script with no filtering, and that pipeline derives parameters only from declared
endpoint schemas. Headers *are* expressible there, but no protocol endpoint declares
`headers:` at all; the location headers are read imperatively inside middleware, which
contributes nothing to the parameter surface. **Control case: `Authorization` is invisible
the same way** — the server genuinely requires Basic auth while the generated document
carries empty `securitySchemes`.
- Git history rules out replacement: `location.ts` was created in `56a37c3640` with the
header *and* query branches already present, so the query channel was additive from day
one. `specs/v2/schema-changelog.md` frames it as preserving header routing for
compatibility.

**Found — the channels are not interchangeable:**

- `LocationMiddleware` is attached **per group** (`packages/protocol/src/api.ts:150-180`)
and applies to every endpoint in that group regardless of whether it declares the query
parameter. So the header reaches operations the query channel cannot (`project.list`,
`permission.saved.*`), and is inert on groups without it (health, server, message,
event, debug, migration) and on session-scoped endpoints, which resolve location from
the session DB row and ignore both channels.
- **Precedence is per field, not per location object** (measured): query beats header beats
`process.cwd()`, but `directory` and `workspaceID` resolve independently and the code uses
`||`, so an empty query value falls through to the header. Consequently an ambient
`{directory, workspaceID}` plus a per-request query carrying only `directory` resolves to
the per-request directory **and the ambient workspace** — a per-request location does not
wholesale-replace the ambient one.
- No first-party client *library* offers an ambient location: `ClientOptions` is
`{baseUrl, fetch, headers}` and every UI (app/web, TUI, CLI) threads location per call
through the query channel. Only `packages/drive`, a single-directory test harness, pins an
ambient header — the same shape this SDK ships.

**Found — a defect in this batch:** the ambient directory header rode the wire raw. Measured
against the live server, a directory literally named `loc%20test` returns **HTTP 500 with an
empty body** when sent raw (the server decodes `%20` into a space and resolves a directory
that does not exist) and **200 with the correct location** when percent-encoded. The
workspace header is *not* decoded server-side, so the escaping is asymmetric by contract.

**Decisions (maintainer, sealed):** the ambient channel stays for now (option A) with its
documentation corrected to state per-field precedence and the coverage boundary; the
directory header is percent-encoded and the workspace header stays verbatim, mirroring the
server. Whether to drop the ambient channel entirely (option B — "convenience does not
justify supporting a channel outside the public contract"; the MCP server will set location
explicitly anyway) or fold it into the query channel (option C) is anchored at **M5 as
issue #37**, decision-first because packaging unblocking freezes the public surface.
33 changes: 26 additions & 7 deletions docs/research/15-opencode-v2-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,32 @@ pinned server implementation. Client-side password resolution lives in the *cons
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
**Location addressing is dual-channel (re-derived at the pin, 2026-08-14; measured
2026-08-16 — research log Q95)**: 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. Three
mechanics matter and are easy to get wrong:

- **Precedence is per field, not per location object**, and the code uses `||`, so an empty
query value falls through to the header. An ambient `{directory, workspaceID}` plus a
per-request query carrying only `directory` resolves to the per-request directory and the
**ambient** workspace.
- **The middleware is attached per group** (`packages/protocol/src/api.ts:150-180`), not per
endpoint that declares the parameter. It therefore reaches operations with no location
query parameter at all (`project.list`, `permission.saved.*`) and is inert on the groups
without it (health, server, message, event, debug, migration) and on session-scoped
endpoints, which resolve location from the session DB row and ignore both channels.
- **Only the directory header is percent-decoded** server-side; the workspace header is read
verbatim, so a client's escaping must be asymmetric to match.

The headers are absent from the document because they were never expressible in it, not
because they were removed: the spec is `OpenApi.fromApi(ClientApi)` over declared endpoint
schemas, and middleware contributes nothing to the parameter surface — `Authorization` is
invisible for exactly the same reason while remaining mandatory. 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).
Expand Down
43 changes: 30 additions & 13 deletions docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,33 @@ introduced gets exercised by a real operation. Sealed inputs: research log Sessi

## Work plan

- [ ] Placement map in the binder, red-test-first; body+query admission for
`shell.create`/`shell.timeout` only through it.
- [ ] Location: request-record property + deepObject route composition; ambient header
default in options/pipeline (explicit query wins by server precedence — no client
merge logic).
- [ ] Envelope profiles: `{location, data}` and 204-bodyless, with binder walls and
contract tests.
- [ ] Curation rows + `Shell.Info1` alias; regen; model closure; PATCH/DELETE;
operation methods + contract tests per op.
- [ ] #28 rider end to end.
- [ ] Live demo against `opencode2 serve`: shell create → list → get → output → remove,
plus session rename → remove.
- [ ] ROADMAP + this plan updated in the same commits.
- [x] Placement map in the binder, red-test-first: a body+query mix merges into one
uniform request model exactly when every query property is the location selector
(`QueryRequestPlan.RidesRequestBody`); the model gains `[JsonIgnore]` query-side
properties, the route builder types itself with the body model, and every other
mix keeps the deliberate wall.
- [x] Location: the hand-written `LocationSelector` spine (the wire's query selector is
an inline `{directory?, workspace?}` object, not `Location.Info`, so it follows the
`SessionParentFilter` precedent) with request-record property + deepObject route
composition; ambient header default in options/pipeline (explicit query wins by
server precedence — no client merge logic).
- [x] Envelope profiles: `{location, data}` (object and list variants) and 204-bodyless,
with binder walls and emitter snapshots; contract tests land with admission.
- [x] Curation rows + `Shell.Info1` alias; regen; model closure; PATCH/DELETE (PATCH
rides the internal `OpenCodeHttpMethod` spine — `HttpMethod.Patch` is absent
downlevel); operation methods + contract tests per op. The verb vocabulary gained
`remove`/`rename`/`timeout`, `RequestTypeName` folds Get like the response name,
and reachability recollects post-alias so an alias target's promoted inline
children stay bound; the alias comparer resolves promoted references structurally.
**`v2.shell.output` deferred:** its inline data object and integer cursor/limit
query params each need a mechanism no other admitted operation needs; it returns
with a later batch.
- [x] #28 rider end to end: the `mutuallyExclusiveQueries` curation section (pairs only,
fail-closed validated), the binder pins each name to the bound query surface, the
route builder refuses the pair before any request, and the no-send contract test
observes it on `message.list`.
- [x] Live demo against `opencode2 serve` v0.0.0-next-17403 (2026-08-16): shell
create → list → get → timeout → remove (204), session rename → remove (204), all
typed; the ambient options location rode the header channel and came back in the
location echo. (`output` deferred with its operation.)
- [x] ROADMAP + this plan updated in the same commits.
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ private static IServiceCollection AddOpenCodeCore(IServiceCollection services)
_ = services.AddSingleton(static provider => new OpenCodeClient(
provider.GetRequiredService<IOptions<OpenCodeClientOptions>>().Value));
_ = services.AddSingleton(static SessionsClient (provider) => provider.GetRequiredService<OpenCodeClient>().Sessions);
_ = services.AddSingleton(static ShellsClient (provider) => provider.GetRequiredService<OpenCodeClient>().Shells);
return services;
}
}
34 changes: 33 additions & 1 deletion src/OpenCode.Sdk/.generated-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
"Internal/ResponseAdapters/SessionCreateResponseAdapter.cs",
"Internal/ResponseAdapters/SessionListResponseAdapter.cs",
"Internal/ResponseAdapters/SessionMessageResponseAdapter.cs",
"Internal/ResponseAdapters/SessionRemoveResponseAdapter.cs",
"Internal/ResponseAdapters/SessionRenameResponseAdapter.cs",
"Internal/ResponseAdapters/SessionResponseAdapter.cs",
"Internal/ResponseAdapters/ShellCreateResponseAdapter.cs",
"Internal/ResponseAdapters/ShellListResponseAdapter.cs",
"Internal/ResponseAdapters/ShellRemoveResponseAdapter.cs",
"Internal/ResponseAdapters/ShellResponseAdapter.cs",
"Internal/ResponseAdapters/ShellTimeoutResponseAdapter.cs",
"Internal/Serialization/MessageListResponseEnvelope.cs",
"Internal/Serialization/OpenCodeErrorJsonConverter.cs",
"Internal/Serialization/OpenCodeJsonContext.cs",
Expand All @@ -21,6 +28,10 @@
"Internal/Serialization/SessionMessageResponseEnvelope.cs",
"Internal/Serialization/SessionMessageToolStateJsonConverter.cs",
"Internal/Serialization/SessionResponseEnvelope.cs",
"Internal/Serialization/ShellCreateResponseEnvelope.cs",
"Internal/Serialization/ShellListResponseEnvelope.cs",
"Internal/Serialization/ShellResponseEnvelope.cs",
"Internal/Serialization/ShellTimeoutResponseEnvelope.cs",
"Internal/Serialization/ToolContentJsonConverter.cs",
"Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs",
"Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs",
Expand All @@ -34,6 +45,8 @@
"Models/FileDiffInfoStatus.cs",
"Models/InvalidCursorError.cs",
"Models/InvalidRequestError.cs",
"Models/LocationInfo.cs",
"Models/LocationInfoProject.cs",
"Models/LocationRef.cs",
"Models/MessageNotFoundError.cs",
"Models/ModelRef.cs",
Expand Down Expand Up @@ -100,8 +113,15 @@
"Models/SessionMessageUser.cs",
"Models/SessionMessageUserTime.cs",
"Models/SessionNotFoundError.cs",
"Models/SessionRenameRequest.cs",
"Models/SessionRevert.cs",
"Models/SessionStructuredError.cs",
"Models/ShellCreateRequest.cs",
"Models/ShellInfo.cs",
"Models/ShellInfoStatus.cs",
"Models/ShellInfoTime.cs",
"Models/ShellNotFoundError.cs",
"Models/ShellTimeoutRequest.cs",
"Models/TokenUsageInfo.cs",
"Models/TokenUsageInfoCache.cs",
"Models/ToolContent.cs",
Expand All @@ -126,7 +146,19 @@
"Sessions/SessionListRequest.cs",
"Sessions/SessionListResponse.cs",
"Sessions/SessionMessageResponse.cs",
"Sessions/SessionRemoveResponse.cs",
"Sessions/SessionRenameResponse.cs",
"Sessions/SessionResponse.cs",
"Sessions/SessionsClient.cs"
"Sessions/SessionsClient.cs",
"Shells/ShellClient.cs",
"Shells/ShellCreateResponse.cs",
"Shells/ShellListRequest.cs",
"Shells/ShellListResponse.cs",
"Shells/ShellRemoveRequest.cs",
"Shells/ShellRemoveResponse.cs",
"Shells/ShellRequest.cs",
"Shells/ShellResponse.cs",
"Shells/ShellTimeoutResponse.cs",
"Shells/ShellsClient.cs"
]
}
18 changes: 9 additions & 9 deletions src/OpenCode.Sdk/.generation-incomplete
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
Generation is incomplete; packages must not be published.
Selected operations: 6
Pending operations: 114
Selected operations: 13
Pending operations: 107
Selected:
- v2.health.get
- v2.message.list
- v2.session.create
- v2.session.get
- v2.session.list
- v2.session.message
- v2.session.remove
- v2.session.rename
- v2.shell.create
- v2.shell.get
- v2.shell.list
- v2.shell.remove
- v2.shell.timeout
Pending:
- v2.agent.get
- v2.agent.list
Expand Down Expand Up @@ -100,8 +107,6 @@ Pending:
- v2.session.question.list
- v2.session.question.reject
- v2.session.question.reply
- v2.session.remove
- v2.session.rename
- v2.session.revert.clear
- v2.session.revert.commit
- v2.session.revert.stage
Expand All @@ -111,12 +116,7 @@ Pending:
- v2.session.switchModel
- v2.session.synthetic
- v2.session.wait
- v2.shell.create
- v2.shell.get
- v2.shell.list
- v2.shell.output
- v2.shell.remove
- v2.shell.timeout
- v2.skill.list
- v2.vcs.diff
- v2.vcs.get
Expand Down
3 changes: 3 additions & 0 deletions src/OpenCode.Sdk/IOpenCodeClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ public interface IOpenCodeClientOptions

/// <summary>Gets the HTTP basic-authentication password; <see langword="null"/> sends anonymous requests.</summary>
public string? Password { get; }

/// <summary>Gets the ambient location sent on every request as location headers, or <see langword="null"/> for none.</summary>
public LocationSelector? Location { get; }
}
Loading