Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
23 changes: 23 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,29 @@ jobs:
ls audit-checks.test.ts audit-cli.test.ts guard-expression.test.ts github/same-repo-guard.test.ts github/close-scripts-guard.test.ts github/write-guard-coverage.test.ts > /dev/null
bun test audit-checks.test.ts audit-cli.test.ts guard-expression.test.ts github/same-repo-guard.test.ts github/close-scripts-guard.test.ts github/write-guard-coverage.test.ts

- name: Test the fork maintenance scripts
# Kept out of the fork-guard gate above: these are fork tooling, not
# guards, and that gate's contents are pinned by the audit. `ls` first
# for the same reason as there: a renamed file must fail, not vanish.
working-directory: script
run: |
ls sync-upstream.test.ts changeset-check.test.ts > /dev/null
bun test sync-upstream.test.ts changeset-check.test.ts

- name: Check the fork changeset
# A PR against swxtch must add its entry to CHANGESET.md. Passed through
# env, never interpolated into the script, since the head ref is
# attacker-chosen on a fork PR.
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_HEAD_REF: ${{ github.head_ref }}
PR_BASE_REF: ${{ github.base_ref }}
# The entry must be new against the base, which the depth-1 checkout
# lacks; the check fetches this commit to read it.
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: bun script/changeset-check.ts

# Correctness-only lint gate. `bun run lint` exits 0 on this repository
# because .oxlintrc.json sets almost everything to "warn" - including
# no-dupe-keys, which is how a duplicate key in an e2e mock config
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,8 @@ jobs:

- name: Run typecheck
run: bun typecheck

- name: Typecheck the fork's scripts
# script/ is not a workspace package, so `bun typecheck` never reaches
# it. script/tsconfig.json scopes this to the files the fork owns.
run: bun run typecheck:script
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- The default branch in this **fork** (`swxtchio/swx-opencode`) is `swxtch`, not `dev`. `dev` and `main` are the
upstream repository's branches, inherited by the fork and not maintained here; a local
- The default branch in this **fork** (`swxtchio/swx-opencode`) is `swxtch`, not `dev`. `dev` is a read-only mirror
of `upstream/dev` that only the sync script below advances; never commit to it. `main` is inherited from
upstream and not maintained here. A local
`refs/remotes/origin/HEAD` cloned before the fork still points at `origin/dev` and is not to be trusted.
Resolve it rather than assuming: `gh repo view swxtchio/swx-opencode --json defaultBranchRef`.
- Use `swxtch` or `origin/swxtch` for diffs and as the base for branches and pull requests.
- Sync with upstream by merging, never rebasing: run `bun script/sync-upstream.ts` from a dedicated worktree. It
fast-forwards `dev` to `upstream/dev` and prepares a `--no-ff` merge onto a new branch off `origin/swxtch`. Land
that PR with a merge commit, never a squash; the script's header documents its report tokens and conflict handling.
- `CHANGESET.md` at the root records every upstream sync and every change this fork carries. A PR against `swxtch`
adds its own `- **#<number>**` entry under the matching area in "Fork changes", which CI checks; the sync script
writes the sync entries, and CI checks those too.

## Branch Names

Expand Down
81 changes: 81 additions & 0 deletions CHANGESET.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Fork changeset

`swxtch` is upstream [`anomalyco/opencode`](https://github.com/anomalyco/opencode) `dev` plus the changes below.
This file records both halves: every upstream sync merged in, and every change this fork carries on top.

How it stays current:

- **Upstream syncs** are recorded by `bun script/sync-upstream.ts`, which writes the entry inside the sync merge
itself, naming its `sync-upstream-<UTC timestamp>` branch. Record conflict resolutions in the sync merge commit
message and summarize them in the entry.
- **Fork changes** are added by the PR that makes them, under the matching area, newest first.
- CI (`script/changeset-check.ts`) fails a PR against `swxtch` that does not add its own entry, compared with the
base: a `- **#<number>**` item under "Fork changes" for a fork change, or, for a sync PR, the item under "Upstream
syncs" naming its branch. Entries may wrap onto indented lines. Only Dependabot PRs are exempt.
- When upstream adopts or supersedes a fork change, update its status instead of deleting the entry. That PR still
adds its own entry, since CI checks for one.

Status: **fork-only** means the change exists only here; **upstreamable** means it fixes upstream's own code and
could be offered back; **partly upstream** means upstream landed an equivalent for part of it.

## Upstream syncs

Newest first. Each entry names the upstream range brought in.

<!-- upstream-syncs: sync-upstream.ts inserts new entries below this line -->

- **2026-09-22** `d870e22c70..2406400f0a`, 17 upstream commits (PR #38, merge `75eced9fee`). Conflicts:
`packages/core/src/filesystem/search.ts` took upstream's, since #50439 fixes the same cycle as #2;
`packages/core/src/npm.ts` kept both import sets; `bun.lock` regenerated.
- **2026-09-20** Fork base: `swxtch` branched from upstream `dev` at `d870e22c70`,
`fix(stats): retry transient query failures (#50205)`.

## Fork changes

### Fork maintenance and CI

- **#46** Upstream sync script and this changeset: `script/sync-upstream.ts` fast-forwards the `dev` mirror and prepares
a `--no-ff` merge for review; `script/changeset-check.ts` keeps this file current; `script/` fork files are now
typechecked. _Fork-only._
- **#44** `local-release.sh` builds and installs a local binary from a `swxtch` commit, with database continuity
checks and rollback. _Fork-only._
- **#38** (in the sync PR) Scope the correctness lint gate to fork-authored code, and stop gating merges on upstream's
e2e suite, whose known flake (#26) this fork does not maintain. _Fork-only._
- **#33** Fail CI when `bun.lock` does not match the manifests; Dependabot's npm ecosystem does not maintain `bun.lock`.
_Fork-only._
- **#30** The workflow guard check accepts a guard written with its operands reversed. _Fork-only._
- **#22** Guard every inherited workflow to `anomalyco/opencode` and enforce it in CI; a scheduled job was attempting
daily writes to the upstream repository. _Fork-only._
- **#7** Point `test` and `typecheck` at this fork, on GitHub-hosted Linux runners. _Fork-only._
- **#1** Dependabot: `astro` 5.7.13 to 7.1.1 in `packages/web`. _Fork-only._

### Runtime fixes

- **#49** `--effort` works on `opencode`, `--mini` and `attach` (one rule: it applies to whichever model declares
it, until an in-app choice, and is never saved); an unknown CLI argument is now named after the help; and `run`
prints the server's real validation error instead of a generic 500. _Fork-only; the unknown-argument message is
upstreamable._
- **#41** Offer max reasoning variants for GPT-6 Sol and Luna through OpenAI, Azure and compatible gateways.
_Upstreamable._
- **#31** `run`: fit the turn summary to the terminal width instead of cutting its tail. _Upstreamable._
- **#28** `Npm.install` compares requested versions, not just dependency names, so a changed pin reinstalls.
_Upstreamable._
- **#27** Plugin compatibility accepts prerelease builds against `engines.opencode` ranges. _Upstreamable._
- **#17** One custom tool file that fails to import no longer kills the prompt with zero parts and no error.
_Upstreamable._
- **#2** Break the filesystem search import cycle that crashed compiled binaries, and guard the layer-node walk.
_Partly upstream:_ upstream #50439 fixed the cycle and its version was taken in the 2026-09-22 sync; the
layer-node guard is fork-only.

### Features

- **#34** Export per-session usage as JSONL, verified against the live database. _Fork-only._
- **#29** `--effort` flag, which rejects an unknown value instead of ignoring it. _Upstreamable._
- **#12** The TUI turn footer shows the model that actually served a routed turn. _Fork-only._
- **#6** Record the model the provider reports for each turn, not only the requested route. _Fork-only._

### Tests

- **#32** Scale the e2e per-call timeouts the shared scaling did not cover. _Upstreamable._
- **#25** Environment-sensitive tests attribute their own failures instead of reporting a synthesized timeout.
_Upstreamable._
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"lint:correctness": "oxlint --config .oxlintrc.gate.json .",
"typecheck:script": "tsgo -p script/tsconfig.json",
"typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
Expand Down
4 changes: 2 additions & 2 deletions script/audit-checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe("auditStepViolations", () => {
],
[
"a step that pipes failure into echo",
{ steps: [{ ...good.steps[0], run: `${AUDIT_STEPS[0].run} || echo "findings above"` }, good.steps[1]] },
{ steps: [{ ...good.steps[0], run: `${AUDIT_STEPS[0]!.run} || echo "findings above"` }, good.steps[1]] },
],
[
"a gate file dropped from the invocation but left in the ls line",
Expand All @@ -135,7 +135,7 @@ describe("auditStepViolations", () => {
good.steps[0],
{
...good.steps[1],
run: good.steps[1].run.replace(" audit-cli.test.ts", "").replace("ls ", "ls audit-cli.test.ts "),
run: good.steps[1]!.run.replace(" audit-cli.test.ts", "").replace("ls ", "ls audit-cli.test.ts "),
},
],
},
Expand Down
152 changes: 152 additions & 0 deletions script/changeset-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { afterAll, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { checkChangeset, readBaseChangeset } from "./changeset-check"
import { changesetMarker, localGit } from "./sync-upstream"

const doc = [
"## Upstream syncs",
"",
"- **2026-09-25** `aaa..bbb`, 3 upstream commits (sync-upstream-20260925-120000). Conflicts: none.",
"- **2026-09-24** `ccc..aaa`, 12 upstream commits. Conflicts: `a.ts` took upstream's",
" (sync-upstream-20260924-080000).",
"- **#91** A fork-change-shaped item in the wrong section.",
"",
"## Fork changes",
"",
"### Area",
"",
"- **#46** Sync script. _Fork-only._",
"- **#7** Something else. Mentions #47 in passing.",
"- **2026-09-23** `x..y`, 1 upstream commit (sync-upstream-20260923-000000). Wrong section.",
"",
].join("\n")
const pr = { number: "46", author: "someone", head: "feature", base: "swxtch" }
const sync = (head: string) => ({ ...pr, number: "90", head })

describe("checkChangeset", () => {
test("passes a PR that adds its own entry", () => {
expect(checkChangeset(doc, pr, "").ok).toBe(true)
})

// GOAL: only an entry counts. A number mentioned in passing, or one that is a prefix of
// another entry, does not record the PR.
test.each(["47", "4"])("fails #%s, which is only mentioned or a prefix of an entry", (number) => {
const result = checkChangeset(doc, { ...pr, number }, "")
expect(result.ok).toBe(false)
expect(result.reason).toContain(`- **#${number}**`)
})

// GOAL: an entry counts only in its own section.
test("fails a fork entry that is only under Upstream syncs", () => {
expect(checkChangeset(doc, { ...pr, number: "91" }, "").ok).toBe(false)
})

test.each([
["CHANGESET.md is missing", undefined, ""],
["the base cannot be read", doc, undefined],
])("fails closed when %s", (_, current, base) => {
expect(checkChangeset(current, pr, base).ok).toBe(false)
})

test("fails a PR number that is not a number", () => {
expect(checkChangeset(doc, { ...pr, number: "46x" }, "").reason).toContain("not a number")
})

// GOAL: a sync PR is checked, not exempted. It passes only by adding the sync entry that
// names its branch, including one wrapped onto a continuation line.
test.each(["sync-upstream-20260925-120000", "sync-upstream-20260924-080000"])(
"passes sync branch %s whose entry names it",
(head) => {
expect(checkChangeset(doc, sync(head), "").ok).toBe(true)
},
)

test.each([
["with no sync entry", "sync-upstream-20260101-000000"],
["whose entry is under Fork changes", "sync-upstream-20260923-000000"],
])("fails a sync branch %s", (_, head) => {
const result = checkChangeset(doc, sync(head), "")
expect(result.ok).toBe(false)
expect(result.reason).toContain(head)
})

// GOAL: reusing an old sync's branch name does not pass on the entry the base already has.
test("fails a branch reusing the name of a sync the base already records", () => {
const result = checkChangeset(doc, sync("sync-upstream-20260925-120000"), doc)
expect(result.ok).toBe(false)
expect(result.reason).toContain("base already records")
})

// GOAL: newness against the base applies to fork entries too, not only sync entries.
test("fails a fork PR whose entry the base already has", () => {
const result = checkChangeset(doc, pr, doc)
expect(result.ok).toBe(false)
expect(result.reason).toContain("base already records #46")
})

// GOAL: a hand branch with the sync prefix is an ordinary PR and needs a PR entry.
test("treats a hand branch named sync-upstream-* as an ordinary PR", () => {
expect(checkChangeset(doc, { ...pr, head: "sync-upstream-script" }, "").ok).toBe(true)
expect(checkChangeset(doc, { ...pr, number: "90", head: "sync-upstream-script" }, "").ok).toBe(false)
})

// GOAL: the remaining exemptions stay narrow, and need no base to decide.
test.each([
["a push", { number: "" }],
["a PR to another base", { number: "9", base: "dev", head: "feature" }],
["Dependabot", { number: "9", author: "dependabot[bot]", base: "swxtch", head: "dependabot/npm/x" }],
])("passes %s without an entry", (_, input) => {
expect(checkChangeset("", input, undefined).ok).toBe(true)
})

// GOAL: the real file carries entries in the shape the check looks for, so a reformat that
// breaks the pattern fails here instead of on every later PR.
test("CHANGESET.md records the PRs it lists in the checked shape", async () => {
const real = await Bun.file(path.join(import.meta.dir, "..", "CHANGESET.md")).text()
expect(checkChangeset(real, { ...pr, number: "2" }, "").ok).toBe(true)
expect(real).toContain(changesetMarker)
})
})

describe("readBaseChangeset", () => {
const root = mkdtempSync(path.join(tmpdir(), "changeset-base-"))
afterAll(() => rmSync(root, { recursive: true, force: true }))
const env = {
...process.env,
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_NOSYSTEM: "1",
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@example.invalid",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@example.invalid",
}
const git = (dir: string, ...args: string[]) => localGit(dir, env)(...args).stdout

// GOAL: CI checks out depth 1, so the base must be fetched to be read. A base without the
// file reads as empty; a base that cannot be fetched reads as unknown, never as empty.
test("fetches the base commit and reads CHANGESET.md from it", () => {
const src = path.join(root, "src")
git(root, "init", "-q", "-b", "swxtch", src)
git(src, "commit", "-q", "--allow-empty", "-m", "before the changeset")
const without = git(src, "rev-parse", "HEAD")
writeFileSync(path.join(src, "CHANGESET.md"), "## Fork changes\n")
git(src, "add", "CHANGESET.md")
git(src, "commit", "-qm", "add the changeset")
const withFile = git(src, "rev-parse", "HEAD")
git(src, "commit", "-q", "--allow-empty", "-m", "the PR head")
const clone = path.join(root, "clone")
git(root, "clone", "-q", "--depth=1", `file://${src}`, clone)
const shallow = localGit(clone, env)

expect(readBaseChangeset(shallow, withFile)).toBe("## Fork changes")
expect(readBaseChangeset(shallow, without)).toBe("")
expect(readBaseChangeset(shallow, "0".repeat(40))).toBeUndefined()
expect(readBaseChangeset(shallow, "not-a-sha")).toBeUndefined()
// A base that has the file but cannot be read is unknown, never "no file".
const failingShow = (...args: string[]) =>
args[0] === "show" ? { code: 128, stdout: "", stderr: "fatal: injected" } : shallow(...args)
expect(readBaseChangeset(failingShow, withFile)).toBeUndefined()
})
})
Loading
Loading