diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 52ce95776..67a582360 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -57,7 +57,7 @@ body:
attributes:
label: opencode server version
description: The `version` field `Server.GetInfoAsync` returns.
- placeholder: 2.0.29
+ placeholder: 2.0.32
validations:
required: true
diff --git a/.github/workflows/consumer-leg.yml b/.github/workflows/consumer-leg.yml
index c44d63846..5b6483537 100644
--- a/.github/workflows/consumer-leg.yml
+++ b/.github/workflows/consumer-leg.yml
@@ -82,10 +82,10 @@ jobs:
# spec/SNAPSHOT.md's identity table is the single owner of the pin, and its "Upstream release
# tag" row is what `refresh-spec --apply` rewrites. The npm version is that tag without its
- # leading "v" - SNAPSHOT.md says so in prose beside the table ("This pin is the release tag
- # `v2.0.3`, so `npm install -g @opencode/cli@2.0.3` installs a server built from exactly this
- # commit"). The dispatch input overrides it, which is how a candidate build is tried without
- # editing the pin.
+ # leading "v" - SNAPSHOT.md says so in prose beside the table, as it read at the v2.0.3 pin
+ # ("This pin is the release tag `v2.0.3`, so `npm install -g @opencode/cli@2.0.3` installs a
+ # server built from exactly this commit"). The dispatch input overrides it, which is how a
+ # candidate build is tried without editing the pin.
- name: Resolve the distributed build version
id: cli
shell: pwsh
diff --git a/README.md b/README.md
index 206bb0655..941424d27 100644
--- a/README.md
+++ b/README.md
@@ -105,7 +105,7 @@ You also need the `opencode` CLI, from the `@opencode/cli` npm scope. Install th
repository pins — later releases usually work, but they are not what the tests run against:
```sh
-npm install -g @opencode/cli@2.0.29
+npm install -g @opencode/cli@2.0.32
```
You do not have to start it. `OpenCodeServer.StartAsync()` in the [quick start](#-quick-start)
diff --git a/docs/guide/connection-modes.md b/docs/guide/connection-modes.md
index e82139e94..004661990 100644
--- a/docs/guide/connection-modes.md
+++ b/docs/guide/connection-modes.md
@@ -210,9 +210,10 @@ Console.WriteLine($"opencode {info.ServerInfo.Version} (pid {info.ServerInfo.Pid
```
That bounded info call is the whole validation recipe for *reaching* the server, and it is
-deliberately yours to write: the SDK carries no version comparand of its own and no network-timeout
-knob yet, so a `CancellationTokenSource` is the honest timeout and your own expectation is the
-honest version check. It is liveness only — a server answering status can still have an empty model catalog
+deliberately yours to write: the SDK carries no version comparand of its own, and its `NetworkTimeout`
+bounds a call that stops making progress (no response headers, or no new body bytes), not the whole
+call, so a `CancellationTokenSource` is the honest deadline and your own expectation is the honest
+version check. It is liveness only — a server answering status can still have an empty model catalog
for a second or two while its plugins activate, which
[choosing a model](getting-started.md#choosing-a-model) covers.
diff --git a/docs/guide/requests.md b/docs/guide/requests.md
index f9002c82a..103a97ac8 100644
--- a/docs/guide/requests.md
+++ b/docs/guide/requests.md
@@ -254,7 +254,7 @@ Both members take a value above zero and at most `int.MaxValue` milliseconds, or
Most generated members say no more than "Gets the action value", because the pinned API document
describes no more. Three families need more than their types to use correctly. Where a rule below
-comes from upstream's implementation at the pin (`v2.0.29`) rather than from the pinned contract,
+comes from upstream's implementation at the pin (`v2.0.32`) rather than from the pinned contract,
it says so.
### Session permissions
diff --git a/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuard.cs b/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuard.cs
new file mode 100644
index 000000000..d8a05ed67
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuard.cs
@@ -0,0 +1,69 @@
+using System.Globalization;
+using System.IO.Abstractions;
+using System.Text.RegularExpressions;
+
+namespace OpenCode.Sdk.Tools.Tests.RepositoryGates;
+
+/// Protects consumer-facing pin mentions from going stale when spec/SNAPSHOT.md's pinned version changes.
+internal sealed partial class ConsumerPinGuard(IFileSystem fileSystem)
+{
+ private const string Version = @"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?";
+
+ public async Task> FindAsync(string root)
+ {
+ var snapshot = await fileSystem.File.ReadAllTextAsync(fileSystem.Path.Combine(root, "spec/SNAPSHOT.md"));
+ var tags = ReleaseTag().Matches(snapshot);
+ if (tags.Count != 1)
+ {
+ return [string.Create(CultureInfo.InvariantCulture,
+ $"spec/SNAPSHOT.md: expected exactly one '| Upstream release tag | `vX.Y.Z` |' row; found {tags.Count} matches.")];
+ }
+
+ var expected = tags[0].Groups["version"].Value;
+ var paths = new[] { fileSystem.Path.Combine(root, "README.md") }
+ .Concat(fileSystem.Directory.EnumerateFiles(fileSystem.Path.Combine(root, "docs/guide"), "*.md", SearchOption.AllDirectories))
+ .Concat(fileSystem.Directory.EnumerateFiles(fileSystem.Path.Combine(root, ".github/ISSUE_TEMPLATE"), "*", SearchOption.AllDirectories))
+ .Select(path => fileSystem.Path.GetRelativePath(root, path).Replace('\\', '/'))
+ .Order(StringComparer.Ordinal);
+ var stale = new List();
+ foreach (var path in paths)
+ {
+ var lines = await fileSystem.File.ReadAllLinesAsync(fileSystem.Path.Combine(root, path));
+ var serverVersion = false;
+ for (var index = 0; index < lines.Length; index++)
+ {
+ var line = lines[index].Trim();
+ if (line.StartsWith("id:", StringComparison.Ordinal))
+ {
+ serverVersion = line is "id: server-version" or "id: \"server-version\"" or "id: 'server-version'";
+ }
+
+ foreach (var groups in PinMention().Matches(lines[index]).Select(static match => match.Groups))
+ {
+ if (groups["placeholder"].Success &&
+ (!serverVersion || !path.StartsWith(".github/ISSUE_TEMPLATE/", StringComparison.Ordinal)))
+ {
+ continue;
+ }
+
+ var version = groups["version"].Value;
+ if (!string.Equals(version, expected, StringComparison.Ordinal))
+ {
+ stale.Add(string.Create(CultureInfo.InvariantCulture,
+ $"{path}:{index + 1}: consumer pin {version}; expected {expected}."));
+ }
+ }
+ }
+ }
+
+ return stale;
+ }
+
+ [GeneratedRegex(@"^\| Upstream release tag \| `v(?" + Version + @")` \|\r?$",
+ RegexOptions.Multiline, matchTimeoutMilliseconds: Timeout.Infinite)]
+ private static partial Regex ReleaseTag();
+
+ [GeneratedRegex("""(?:\bnpm install -g (?["'])?@opencode/cli@|at the pin \(`v|(?^\s*placeholder:\s*)(?["'])?)(?""" + Version + @")(?(quote)\k)",
+ RegexOptions.None, matchTimeoutMilliseconds: Timeout.Infinite)]
+ private static partial Regex PinMention();
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuardTests.cs b/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuardTests.cs
new file mode 100644
index 000000000..c01d840bb
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/ConsumerPinGuardTests.cs
@@ -0,0 +1,97 @@
+using OpenCode.Sdk.Tools.Tests.RepositoryGates.Support;
+using OpenCode.Sdk.Tools.Tests.Support;
+using Testably.Abstractions;
+using TUnit.Assertions.Enums;
+
+namespace OpenCode.Sdk.Tools.Tests.RepositoryGates;
+
+public sealed class ConsumerPinGuardTests
+{
+ [Test]
+ public async Task ConsumerPinGuard_Should_Keep_Consumer_Pins_Aligned_With_The_Snapshot()
+ {
+ var fileSystem = new RealFileSystem();
+ var stale = await new ConsumerPinGuard(fileSystem).FindAsync(new RepositoryRoot(fileSystem).Locate());
+
+ await Assert.That(stale).IsEmpty();
+ }
+
+ [Test]
+ [Arguments("8.7.6")]
+ [Arguments("9.8.7")]
+ public async Task ConsumerPinGuard_Should_Report_Every_Stale_Site_In_Path_Then_Line_Order(string pin)
+ {
+ var scenario = new RepositoryGateScenario()
+ .WithFile("spec/SNAPSHOT.md", $"| Upstream release tag | `v{pin}` |")
+ .WithFile("docs/guide/nested/Z.md", "at the pin (`v4.0.1`)\nnpm install -g @opencode/cli@3.0.1")
+ .WithFile("README.md", $"npm install -g @opencode/cli@{pin}\n\nnpm install -g @opencode/cli@2.0.1; npm install -g @opencode/cli@1.0.1")
+ .WithFile("docs/guide/A.md", $"at the pin (`v{pin}`)\n\n\nat the pin (`v5.0.1`)")
+ .WithFile(".github/ISSUE_TEMPLATE/nested/bug.yml", "id: package-version\nplaceholder: 7.0.1\nid: server-version\nplaceholder: 6.0.1\nid: os\nplaceholder: 8.0.1")
+ .WithFile("docs/guide/ignored.txt", "npm install -g @opencode/cli@0.0.1")
+ .WithFile("docs/adr/history.md", "npm install -g @opencode/cli@0.0.1");
+ await scenario.BuildAsync();
+
+ var stale = await new ConsumerPinGuard(scenario.FileSystem).FindAsync(scenario.Root);
+
+ await Assert.That(stale).IsEquivalentTo(
+ [$".github/ISSUE_TEMPLATE/nested/bug.yml:4: consumer pin 6.0.1; expected {pin}.",
+ $"README.md:3: consumer pin 2.0.1; expected {pin}.",
+ $"README.md:3: consumer pin 1.0.1; expected {pin}.",
+ $"docs/guide/A.md:4: consumer pin 5.0.1; expected {pin}.",
+ $"docs/guide/nested/Z.md:1: consumer pin 4.0.1; expected {pin}.",
+ $"docs/guide/nested/Z.md:2: consumer pin 3.0.1; expected {pin}."], CollectionOrdering.Matching);
+ }
+
+ [Test]
+ [Arguments("Observed on `@opencode/cli@1.2.3`.")]
+ [Arguments("Verified on `@opencode/cli@1.2.3`.")]
+ [Arguments("confirmed at `v1.2.3`.")]
+ [Arguments("a 1.2.3 service re-applies its saved env")]
+ public async Task ConsumerPinGuard_Should_Preserve_Historical_Versions(string evidence)
+ {
+ var scenario = new RepositoryGateScenario()
+ .WithFile("spec/SNAPSHOT.md", "| Upstream release tag | `v8.7.6` |")
+ .WithFile("README.md", "npm install -g @opencode/cli@8.7.6")
+ .WithFile("docs/guide/evidence.md", evidence)
+ .WithFile(".github/ISSUE_TEMPLATE/bug.yml", "id: server-version\nplaceholder: 8.7.6");
+ await scenario.BuildAsync();
+
+ var stale = await new ConsumerPinGuard(scenario.FileSystem).FindAsync(scenario.Root);
+
+ await Assert.That(stale).IsEmpty();
+ }
+
+ [Test]
+ [Arguments("\"")]
+ [Arguments("'")]
+ public async Task ConsumerPinGuard_Should_Report_Quoted_Stale_Sites(string quote)
+ {
+ var scenario = new RepositoryGateScenario()
+ .WithFile("spec/SNAPSHOT.md", "| Upstream release tag | `v8.7.6` |")
+ .WithFile("README.md", $"npm install -g {quote}@opencode/cli@8.7.6{quote}\nnpm install -g {quote}@opencode/cli@2.0.1{quote}")
+ .WithFile("docs/guide/evidence.md", $"Observed on `@opencode/cli@2.0.15`; {quote}@opencode/cli@1.2.3{quote}.")
+ .WithFile(".github/ISSUE_TEMPLATE/bug.yml", $"id: {quote}server-version{quote}\nplaceholder: {quote}6.0.1{quote}\nplaceholder: 5.0.1\nid: {quote}package-version{quote}\nplaceholder: {quote}7.0.1{quote}");
+ await scenario.BuildAsync();
+
+ var stale = await new ConsumerPinGuard(scenario.FileSystem).FindAsync(scenario.Root);
+
+ await Assert.That(stale).IsEquivalentTo(
+ [".github/ISSUE_TEMPLATE/bug.yml:2: consumer pin 6.0.1; expected 8.7.6.",
+ ".github/ISSUE_TEMPLATE/bug.yml:3: consumer pin 5.0.1; expected 8.7.6.",
+ "README.md:2: consumer pin 2.0.1; expected 8.7.6."], CollectionOrdering.Matching);
+ }
+
+ [Test]
+ [Arguments("", "spec/SNAPSHOT.md: expected exactly one '| Upstream release tag | `vX.Y.Z` |' row; found 0 matches.")]
+ [Arguments("| Upstream release tag | v8.7.6 |", "spec/SNAPSHOT.md: expected exactly one '| Upstream release tag | `vX.Y.Z` |' row; found 0 matches.")]
+ [Arguments("| Upstream release tag | `v8.7.6` |\n| Upstream release tag | `v9.8.7` |", "spec/SNAPSHOT.md: expected exactly one '| Upstream release tag | `vX.Y.Z` |' row; found 2 matches.")]
+ public async Task ConsumerPinGuard_Should_Explain_Invalid_Snapshot_Rows(string snapshot, string expected)
+ {
+ var scenario = new RepositoryGateScenario().WithFile("spec/SNAPSHOT.md", snapshot);
+ await scenario.BuildAsync();
+
+ var stale = await new ConsumerPinGuard(scenario.FileSystem).FindAsync(scenario.Root);
+
+ await Assert.That(stale).IsEquivalentTo([expected], CollectionOrdering.Matching);
+ }
+}