- JavaScript 58.6%
- Shell 41.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| app | ||
| bin | ||
| docs | ||
| patches | ||
| scripts | ||
| test | ||
| .gitignore | ||
| FORK.md | ||
| PATCHES.md | ||
| README.md | ||
| UPSTREAM.md | ||
| VERSION | ||
ilands-runner (opencode fork)
A hard fork of iLands Runner 0.1.28 that makes opencode a first-class
harness, so an opencode agent can hold a persistent identity, social life and
inbox on iLands.
Upstream certifies exactly five harnesses — claude-code, codex, openclaw,
hermes, pi. This fork adds a sixth.
Read
FORK.mdbefore you redistribute anything. The upstream release ships noLICENSE,package.jsonis"private": true, and no repository is published. This tree is proprietary code that we have modified locally; it exists so the change can travel upstream as a patch series, not as a copy of their codebase.
1. Fork identity
| Upstream product | iLands Runner, "BYOA local runner CLI for iLands Agent Plane" |
| Vendor | PawLogic Inc. (site © PawLogic Limited) |
| Forked from | release 0.1.28, published 2026-09-23T07:30:43.916Z |
| Artifact | ilands-runner-0.1.28-linux-arm64.tar.gz |
| SHA-256 (pinned) | ea89fd5cd52351908f11eee449a5f42ae75f674cb3b49cbcc3bfc305615e1627 |
| Baseline commit | 1b1e58b — the pristine tarball, byte-for-byte |
| This build | 0.1.28+opencode.1 (see VERSION) |
| Series | every deviation from baseline 1b1e58b, one commit each, exported to patches/series/ and compacted into patches/master.patch (live diffstat: patches/series/README.md) |
| Platform | linux-arm64 (this machine); the upstream release also ships linux-x64 |
| Git history of upstream | none available — they publish no repository |
Because there is no upstream git history to branch from, commit 1 is the
published binary snapshot. Every later commit is one logical deviation, which
makes the fork re-baseable onto 0.1.29 by replaying the same patches.
Constants live in scripts/lib.sh (UPSTREAM_VERSION, UPSTREAM_SHA256,
BASELINE_COMMIT) — change them there and re-run the scripts when rebasing.
2. Status
Linux only (linux-x64, linux-arm64). Windows and macOS are out of scope —
the upstream Windows onboarding path is a separate client/handoff protocol and
is deliberately untouched.
| Area | State |
|---|---|
Harness contract (types, release contract, catalog, preflight) |
done |
opencode ACP adapter |
done |
| Heartbeat workspace injection (project config, MCP, skills) | done |
| Gateway wire shim | done — required, see docs/gateway-probe.md |
opencode plugin install --target opencode |
done — installs the skill directory (see §7) |
| Upstream auto-update neutralised | done — FORKED_RUNNER in app/dist/update.js |
| Patch series reproduces this tree | done — scripts/build.sh (series and master.patch) |
| Live end-to-end onboarding | done — service connected, heartbeats running |
Server-side opencode enum |
not done — the blocker, see §3 |
3. What it takes to permit opencode as a harness
Two independent halves. The client half is written, tested and live in this
tree. The server half is three request/response enums, and it is the only thing
blocking harness: "opencode" from being a supported value.
3.1 Their backend — exactly
| # | Call | Field | Today | What has to change |
|---|---|---|---|---|
| 1 | POST {gateway}/byoa/runner/device-authorizations (create, and the resume path) |
request body harness |
harness: "opencode" → 400 {"error":{"code":"HARNESS_INVALID","message":"harness invalid"}}. The same body with codex or pi → 201 activation created. |
Add "opencode" to that enum. |
| 2 | POST {gateway}/byoa/runner/device-authorizations/{id}/passport |
body passport.sourceReport.sourceAgent |
Not probeable from outside — it answers 401 before schema validation. |
Add "opencode" to the same enum. If sourceAgent is validated against one shared harness list, this is the whole server-side change. |
| 3 | Responses that echo the harness back: the activation object, POST …/device-authorizations/token, POST …/authorizations/{id}/refresh |
response harness |
Echoes whatever was claimed on the wire — today codex, because of the shim. |
Must accept and return "opencode". The client runs requireHarness(credentials.harness) and fails closed on an unknown value. |
Three things worth stating in review so they do not get missed:
- The passport hash must stay consistent. The Runner hashes its
sourceReport.sourceAgentcopy before upload and the server recomputes it when the Passport arrives. Once both ends sayopencodethe normalization disappears, but neither side may re-case, re-order or alias the value. - Nothing else on the wire carries a harness. The live/heartbeat websocket
frames carry only
type,runIdandmessage; the structured tool RPCs (tool-wrapper.js) carry no harness field; every otherharnessinapp/dist/daemon.jsis local status or log output. Rows 1–3 are the complete server-side surface. - Windows stays out of scope.
handoff claim/windows-handoffis untouched, so no Windows enum needs to widen for this.
The rejection, the method and the raw responses:
docs/gateway-probe.md.
3.2 This code — exactly
Take patches/master.patch (everything) or the series in numeric order
(§4.2). File by file, what each change is for:
| File | What changes | Patch |
|---|---|---|
app/dist/harness/release-contract.json |
add opencode to adapterImplementedHarnesses, and to targetHarnesses/certifiedHarnesses for linux-x64 and linux-arm64. Leave managedRuntimeHarnesses alone — opencode is an external, user-owned runtime, so this is the openclaw/hermes/pi shape, not the claude-code/codex shape. |
0002 |
app/dist/types.js, app/dist/types.d.ts |
AGENT_HARNESS_KINDS += opencode; HarnessProtocol += opencode-acp; HarnessAuthEnvironment += OPENCODE_CONFIG, OPENCODE_CONFIG_DIR. |
0002 |
app/dist/harness/types.d.ts |
RenderedRunPrompt gains openCodeConfigPath. |
0002 |
app/dist/harness/catalog.js |
HARNESS_RUNTIME_CATALOG.opencode — protocol: "opencode-acp", capabilities streaming/abort/healthProbe: true, steer: false (ACP has no mid-turn steering), sessionPersistence: false (a fresh session per heartbeat). |
0002 |
app/dist/harness/preflight.js |
HARNESS_PREFLIGHT_DESCRIPTORS.opencode — defaultCommand: "opencode", commandEnv: "ILANDS_RUNNER_OPENCODE_BIN", version >=1.0.0 <2.0.0, authStatusArgs: ["auth","list"] with authStatusRejectPattern: "\\b0 credentials\\b" (opencode exits 0 on an empty store), probes acp --help and run --help, requiresLinuxSandbox: false; captureHarnessAuthEnvironment += the OPENCODE_* keys. |
0002 |
app/dist/harness/adapters.js |
register opencode: { buildInvocation: buildOpenCodeAcpInvocation, run: runOpenCodeAcp }. |
0002 |
app/dist/harness/opencode-acp.js + .d.ts |
the adapter: ACP session/new with mcpServers: [] (the transport rejects stdio MCP servers), session/prompt, session/cancel, success gated on end_turn, XDG_CONFIG_HOME pointed at <workspace>/.opencode-xdg so the operator's global config never leaks into a heartbeat, credentials left under XDG_DATA_HOME. |
0002 |
app/dist/harness/prompt.js + .d.ts |
buildOpenCodeProjectConfig() writes the workspace opencode.json: the stdio MCP server as mcp.ilands.type: "local", the unattended allow-all permission policy, and an optional model. |
0002, 0018 |
app/dist/heartbeat-workspace.js |
skill discovery link at .opencode/skills, beside the existing .agents/ and .claude/ links. |
0002 |
app/dist/product-links.js |
OPENCODE_INSTALL_URL + the harnessInstallUrl case. |
0002 |
app/dist/cli.js, app/dist/plugin.js |
requireHarness / requirePluginTarget / --help / the Runner skill text all list opencode; plugin install --target opencode writes the skill directory (opencode's plugin command only accepts published npm modules). |
0003, 0010 |
app/dist/passport.js |
requireSourceAgent accepts opencode in a v2 sourceReport.sourceAgent. |
0004 |
app/dist/config.js |
version regex accepts the 0.1.28+opencode.1 suffix; harness-auth env allowlist += OPENCODE_*. |
0002, 0004 |
app/dist/harness/run-once.js |
resolveHarness() accepts opencode — this is the gate the daemon runs over a live message and it is why an unknown harness was refused before; it also records presentedParentDmIds in active-run.json for the reply binding. |
0010, 0014 |
app/dist/daemon.js |
the dl / ilands tool preflight uses the shared 15 s timeout instead of its own 2 s default (it was killing dl --version and parking the daemon in failed). |
0013 |
app/dist/ilands-reactive-tools.js, tool-registry.js, tool-wrapper.js, harness/prompt.js |
replyTo: "current" — an agent answers the Parent message it was shown instead of copying a numeric id, the value is refused when the recorded active run is stale, and the runtime prompt teaches the field. Independent of opencode; a correctness fix we would contribute anyway. |
0014, 0017 |
app/dist/update.js, VERSION, app/package.json |
fork-only, keep: FORKED_RUNNER disables upstream auto-update (checkForUpdate → available: false, disabledReason: "forked_build"), and the version carries the +opencode.1 suffix. Upstream would take the first and drop the second. |
0004 |
Everything else in the series (0001, 0005–0009, 0011, 0012,
0015, 0016, 0019–0024, and anything after) is this repository's own
bookkeeping: the docs, the scripts, the reproduction build.
3.3 The shim to delete last
Until §3.1 lands, the Runner claims codex on the wire. After it lands:
- delete
app/dist/harness-wire.jsandapp/dist/harness-wire.d.ts; - in
app/dist/byoa/device-auth.js, dropwireHarness()from thedevice-authorizationsbodies, droppassportForWire()from the Passport upload and from the hash input, and drop the inboundlocalHarnessFromWire()parse; - in
app/dist/cli.js, go back torequireHarness(result.credentials.harness)with no hint.
Three call sites plus one file — that is the entire shim. The other half of
patch 0004 (the update isolation and the version suffix) stays.
4. Instruction manual
Everything below is runnable from a fresh checkout of this repository. The
scripts never install anything into $HOME except where a step says so
explicitly.
4.1 Fetch the pristine upstream baseline
scripts/fetch-upstream.sh
Downloads ilands-runner-0.1.28-linux-arm64.tar.gz from the signed manifest,
verifies it against the pinned SHA-256, and caches it in upstream-cache/
(gitignored, re-fetchable). Every other script requires it first.
4.2 Apply the patch
Generated by scripts/record-patches.sh with git format-patch, so every
patch is one reviewable commit with its message. Two forms of the same change —
both are proven by scripts/build.sh to reproduce the committed tree from a
pristine tarball. Apply in numeric order.
| Form | File | Use it when |
|---|---|---|
| one master patch | patches/master.patch |
you want a single file to slap onto a pristine tree |
| reviewable series | patches/series/*.patch |
you want to take (or reject) one commit at a time |
Option A — the master patch, one file:
scripts/fetch-upstream.sh
mkdir -p /tmp/runner-tree
tar -xzf upstream-cache/ilands-runner-0.1.28-linux-arm64.tar.gz \
-C /tmp/runner-tree --strip-components=1
git apply --whitespace=error --directory=/tmp/runner-tree --unsafe-paths \
patches/master.patch
git am patches/master.patch also works, if you want every commit message and
therefore one commit per logical change. The series carries the count itself —
patches/series/README.md — so no number in this document can drift.
Option B — the series, as commits, for review or for sending upstream:
mkdir /tmp/runner-review && cd /tmp/runner-review && git init -q
tar -xzf /path/to/ilands-runner/upstream-cache/ilands-runner-0.1.28-linux-arm64.tar.gz \
--strip-components=1
git add -A && git commit -qm "baseline: upstream iLands Runner 0.1.28"
for patch in /path/to/ilands-runner/patches/series/*.patch; do git am "$patch"; done
Useful subsets:
git apply --stat patches/series/*.patch # sizes, per patch
git apply --check patches/series/0018-*.patch # dry run: does it fit?
Only apply what you intend to take. The upstream contribution is 0002,
0003, 0010, 0013, 0014, 0017, 0018 plus the passport.js hunk of
0004 — UPSTREAM.md gives the exact commands, and they
exclude our test/ hunks, which do not exist in an upstream tree. Every other
patch is this fork's own bookkeeping (the docs, the scripts, the reproduction
build) or the temporary gateway shim; the subject line says which.
4.3 Verify
scripts/verify.sh
Runs, in order:
- upstream tarball SHA-256 against the pinned value,
node --checkover every shipped module (there is nonpm run typecheck— see the boundaries),test/*.test.mjs,- fork version must report exactly
0.1.28+opencode.1, harness prepare --harness opencodeagainst your real credential store (needs an authenticated opencode session; the second run proves an empty store is rejected),update checkmust reportdisabledReason: "forked_build",scripts/build.sh— the reproduction proof, twice: pristine upstream + the series, and pristine upstream +patches/master.patch, eachdiff -red againstgit archive HEAD.
If step 7 prints both ok: lines, the series and the master patch are each
sufficient on their own to rebuild this fork.
4.4 Change the fork, then regenerate
# edit app/dist/** as needed, then:
for f in $(find app/dist -name '*.js'); do node --check "$f"; done
node test/harness-surface.test.mjs # or run all of test/*.test.mjs
git add -A && git commit -m "fix: describe the change in one line"
scripts/record-patches.sh # -> patches/series/*.patch + patches/master.patch
scripts/verify.sh # must be green before you trust it
patches/ is regenerated output and is excluded from its own series, so
record-patches.sh never nests patches inside patches. The commit message
becomes the patch subject, so write it as one.
4.5 Rebase onto a newer upstream release
- Bump
UPSTREAM_VERSION/UPSTREAM_SHA256/UPSTREAM_TARBALL/UPSTREAM_ROOT/BASELINE_COMMITinscripts/lib.sh. scripts/fetch-upstream.sh— re-download and re-pin.- Create the new baseline commit from the pristine tarball, then re-apply the
series:
git am patches/series/*.patch(expect conflicts only where their files moved). scripts/record-patches.sh && scripts/verify.sh.
Upstream auto-update can never do this for you: FORKED_RUNNER makes
checkForUpdate return available: false with disabledReason: "forked_build",
so 0.1.29 will never silently overwrite app/dist/.
4.6 Run it without installing anything
scripts/run.sh --version # prints 0.1.28+opencode.1
scripts/run.sh doctor --harness opencode
scripts/run.sh harness prepare --harness opencode
scripts/run.sh <args> executes app/dist/cli.js with a Node 22 runtime
(host node if present, otherwise the runtime bundled in the upstream tarball,
extracted to .cache/).
4.7 Operate the service
ilands-runner service install # writes the unit, daemon-reload, enable, restart
ilands-runner service status # connection, daemon state, version
ilands-runner service restart
ilands-runner service logs --lines 50
ilands-runner doctor --harness opencode
systemctl --user status ai.ilands.runner
service install writes ~/.config/systemd/user/ai.ilands.runner.service
pointing at ~/.local/bin/ilands-runner and a supervisor script that appends to
~/.ilands-runner/logs/daemon.log. The binary must be on the unit's PATH
(~/.local/bin), and opencode must be on it too.
Note.
service installwaits 20 s for the daemon to reportconnected(DEFAULT_WAIT_TIMEOUT_MSinapp/dist/service.js). A cold start includes a harness preflight of up to 15 s, so the command can exit non-zero while the service is in fact healthy — checkservice statusbefore re-running it.
4.8 Pin the model
opencode acp has no --model flag (unlike opencode run), so the only
lever is config. The Runner writes model into the heartbeat workspace's
opencode.json only when the operator asks for it:
export ILANDS_RUNNER_OPENCODE_MODEL=opencode/mimo-v2.6-flash-free
ilands-runner service install # bakes the variable into the systemd unit
ilands-runner service status # confirm connection recovered
Left unset, no model key is written at all and opencode keeps its own default.
Verify the pin took effect on the next heartbeat:
grep '"model"' ~/.ilands-runner/agents/<agent-id>/opencode.json
5. Layout
upstream-cache/ pristine upstream release tarball (gitignored, re-fetchable)
app/dist/ the fork — upstream tsc output plus our patches
app/dist/*.d.ts hand-written type surface beside every module
app/package.json upstream manifest (private, unlicensed — see FORK.md)
bin/ilands-runner upstream release launcher
docs/ design notes and probe evidence
patches/ master.patch (one file) + series/ (reviewable commits)
scripts/ fetch / build / verify / record-patches / run
test/ behavioural tests for the opencode harness surface
../setup.md the single instruction to paste into opencode
6. Quick start
scripts/fetch-upstream.sh # verify and unpack the upstream release
scripts/verify.sh # syntax, tests, harness prepare, patch reproduction
scripts/run.sh --version # run the fork in place, nothing installed
7. Boundaries worth knowing
- Gateway shim. Everything local is honestly
opencode. The single exception is the gateway boundary, where the harness string is translated to/fromcodexbecause the server's enum rejectsopencodewithHARNESS_INVALID. Translation lives in exactly one module (app/dist/harness-wire.js) at three outbound call sites plus one inbound parse, so it can be deleted in a single upstream commit once PawLogic addsopencodeto their enum — §3.3 is the deletion list. Evidence:docs/gateway-probe.md. opencode plugin <module>only accepts published npm modules, soplugin install --target opencodewrites<config-root>/skills/ilands-runnerrather than registering a plugin. Same result for the agent.- Config isolation is
XDG_CONFIG_HOME, notOPENCODE_CONFIG/OPENCODE_CONFIG_DIR. The adapter points it at<workspace>/.opencode-xdg; credentials stay untouched because they live underXDG_DATA_HOME. Override withILANDS_RUNNER_OPENCODE_CONFIG_HOME. - No steering.
opencode acpcannot inject into an active prompt turn, soheartbeat.steerlogsdelivered: 0. - No typecheck. No
src/, notsconfig.json, and upstream's.js.mapfiles carry nosourcesContent, so verification isnode --checkplustest/.
The full list, with the reason for each: PATCHES.md.
8. Documents
| File | What it holds |
|---|---|
FORK.md |
provenance, licensing status, the contribution plan |
PATCHES.md |
what every patch changes and why, including the ACP-vs-serve decision |
UPSTREAM.md |
the letter to PawLogic, ready to send |
docs/gateway-probe.md |
the HARNESS_INVALID evidence, with method |
patches/README.md |
which of the two patch artefacts to apply, and how |
patches/master.patch |
the whole fork as one file |
patches/series/README.md |
generated diffstat of the series |