diff --git a/packages/extension/package.json b/packages/extension/package.json
index 598d47d35..1aebef095 100644
--- a/packages/extension/package.json
+++ b/packages/extension/package.json
@@ -451,6 +451,7 @@
"lint:skills": "node scripts/lint-skills.mjs --known scripts/lint-skills-known.txt",
"smoke:fleet": "node scripts/fleet_beta_smoke.mjs",
"fetch:opencode": "node scripts/fetch_opencode.mjs",
+ "build:binary": "node scripts/build_binary.mjs",
"opencode:dev": "node scripts/opencode_dev.mjs",
"opencode:build": "node scripts/opencode_dev.mjs build",
"opencode:pin": "node scripts/opencode_dev.mjs pin",
diff --git a/packages/extension/scripts/build_binary.mjs b/packages/extension/scripts/build_binary.mjs
new file mode 100644
index 000000000..ccfdcb146
--- /dev/null
+++ b/packages/extension/scripts/build_binary.mjs
@@ -0,0 +1,198 @@
+#!/usr/bin/env node
+// build_binary.mjs — compile the opencode engine binary from the materialized
+// overlay tree (stock upstream + overlay applied on top). Phase 2a of
+// fork-absorption (#1094): replaces the fork-release download path
+// (`fetch:opencode --release`) with a local build.
+//
+// USAGE
+// node scripts/build_binary.mjs # full recipe
+// node scripts/build_binary.mjs --work
# reuse an existing materialized tree
+//
+// Prerequisites: bun (https://bun.sh) — the build uses `bun build --compile`.
+//
+// The build produces a single-platform binary at
+// vendor/opencode//opencode
+// with .sha256 and .source sidecars (matching the fetch_opencode.mjs contract).
+import { createHash } from "node:crypto";
+import { spawnSync } from "node:child_process";
+import {
+ chmodSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { homedir } from "node:os";
+import { delimiter, dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const EXT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
+const REPO_ROOT = join(EXT_ROOT, "..", "..");
+const BUNDLE_PKG = join(REPO_ROOT, "packages", "app-bundle");
+
+const args = process.argv.slice(2);
+const flag = (n) => {
+ const i = args.indexOf(`--${n}`);
+ return i >= 0 ? args[i + 1] : undefined;
+};
+
+const fail = (msg, code = 1) => {
+ console.error(`[build:binary] FAIL: ${msg}`);
+ process.exit(code);
+};
+
+const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
+
+// ── bun resolution (same ladder as fetch_opencode.mjs) ──────────────────────
+function resolveBun() {
+ if (process.env.AMICODE_BUN) return process.env.AMICODE_BUN;
+ const which = spawnSync("which", ["bun"], { encoding: "utf8" });
+ if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
+ const fallback = join(homedir(), ".bun", "bin", "bun");
+ if (existsSync(fallback)) return fallback;
+ fail(
+ "bun not found — the binary build requires bun (https://bun.sh).\n" +
+ " Install it: curl -fsSL https://bun.sh/install | bash\n" +
+ " Or set: AMICODE_BUN=/path/to/bun",
+ );
+}
+
+const run = (cmd, cmdArgs, cwd, note) => {
+ console.log(`[build:binary] ${note}: ${cmd} ${cmdArgs.join(" ")} (cwd=${cwd})`);
+ const r = spawnSync(cmd, cmdArgs, { cwd, stdio: "inherit" });
+ if (r.status !== 0) fail(`${note} failed (exit ${r.status})`, 2);
+};
+
+// ── platform key (matches fetch_opencode.mjs / opencode.lock.json keys) ─────
+const platformKey = `${process.platform}-${process.arch}`;
+
+// ── load the lock for OPENCODE_VERSION ──────────────────────────────────────
+const lockPath = join(EXT_ROOT, "opencode.lock.json");
+if (!existsSync(lockPath)) fail(`opencode.lock.json not found at ${lockPath}`);
+const lock = JSON.parse(readFileSync(lockPath, "utf8"));
+if (!lock.version) fail("opencode.lock.json has no version field");
+const version = lock.version;
+console.log(`[build:binary] version from lock: ${version}`);
+console.log(`[build:binary] platform: ${platformKey}`);
+
+// ── load the app-bundle manifest for overlay provenance ─────────────────────
+const manifestPath = join(BUNDLE_PKG, "manifest.json");
+let manifestSha = "unknown";
+if (existsSync(manifestPath)) {
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
+ manifestSha = manifest.fork_sha ?? "unknown";
+} else {
+ console.warn("[build:binary] WARNING: no app-bundle manifest.json — provenance will say 'unknown'");
+}
+
+// ── materialized tree (reuse build:app's tree or create one) ────────────────
+const work = flag("work") ?? process.env.AMICODE_APP_BUNDLE_WORK ?? join(BUNDLE_PKG, ".materialized");
+
+// Overlay staleness check — same mechanism as build_app_bundle.mjs
+const OVERLAY_STAMP = join(work, ".overlay-stamp");
+const overlayVersion = (() => {
+ try {
+ const m = JSON.parse(readFileSync(manifestPath, "utf8"));
+ return `${m.fork_sha ?? ""}:${m.promoted_at ?? ""}`;
+ } catch { return null; }
+})();
+const cachedVersion = (() => {
+ try { return readFileSync(OVERLAY_STAMP, "utf8").trim(); }
+ catch { return null; }
+})();
+const cacheExists = existsSync(join(work, "package.json"));
+const cacheStale = cacheExists && overlayVersion && cachedVersion !== overlayVersion;
+
+if (cacheStale) {
+ console.log("[build:binary] overlay changed (manifest fork_sha differs from cached stamp) — clearing stale .materialized");
+ console.log(`[build:binary] cached: ${cachedVersion ?? "(none)"}`);
+ console.log(`[build:binary] current: ${overlayVersion}`);
+ rmSync(work, { recursive: true, force: true });
+}
+
+if (!existsSync(join(work, "package.json"))) {
+ run(
+ process.execPath,
+ [join(BUNDLE_PKG, "scripts", "materialize.mjs"), "--out", work],
+ REPO_ROOT,
+ "materialize (canonical base + overlay)",
+ );
+ // Stamp so subsequent builds can detect staleness
+ if (overlayVersion) writeFileSync(OVERLAY_STAMP, overlayVersion + "\n");
+} else {
+ console.log(`[build:binary] reusing materialized tree at ${work}`);
+ // Backfill stamp for trees materialized before this check existed
+ if (!cachedVersion && overlayVersion) {
+ writeFileSync(OVERLAY_STAMP, overlayVersion + "\n");
+ console.log("[build:binary] backfilled .overlay-stamp for existing .materialized tree");
+ }
+}
+
+const opencodePkg = join(work, "packages", "opencode");
+if (!existsSync(opencodePkg))
+ fail(`${work} has no packages/opencode — not a materialized tree`);
+
+// ── bun install in the materialized tree ────────────────────────────────────
+const bun = resolveBun();
+console.log(`[build:binary] bun: ${bun}`);
+run(bun, ["install"], work, "bun install (materialized tree deps)");
+
+// ── build the binary ────────────────────────────────────────────────────────
+// The fork's build recipe: Script reads OPENCODE_VERSION and OPENCODE_CHANNEL
+// from env. OPENCODE_RELEASE must NOT be set (it triggers release upload).
+// --single: build only the current platform
+// --skip-install: deps already installed above
+// --skip-embed-web-ui: the shelf serves the app separately
+const buildEnv = {
+ ...process.env,
+ OPENCODE_VERSION: version,
+ OPENCODE_CHANNEL: "dev",
+ PATH: `${dirname(bun)}${delimiter}${process.env.PATH ?? ""}`,
+};
+// Defensive: ensure OPENCODE_RELEASE is NOT set
+delete buildEnv.OPENCODE_RELEASE;
+
+console.log(`[build:binary] building with OPENCODE_CHANNEL=dev OPENCODE_VERSION=${version}`);
+const buildResult = spawnSync(
+ bun,
+ ["run", "script/build.ts", "--single", "--skip-install", "--skip-embed-web-ui"],
+ { cwd: opencodePkg, env: buildEnv, stdio: "inherit" },
+);
+if (buildResult.status !== 0) fail(`binary build failed (exit ${buildResult.status})`);
+
+// ── locate the artifact ─────────────────────────────────────────────────────
+const artifactName = `opencode-${platformKey}`;
+const artifact = join(opencodePkg, "dist", artifactName, "bin", "opencode");
+if (!existsSync(artifact))
+ fail(`built binary not found at ${artifact} — build produced no artifact for ${platformKey}`);
+
+console.log(`[build:binary] built artifact: ${artifact}`);
+
+// ── install into vendor/ ────────────────────────────────────────────────────
+const destDir = join(EXT_ROOT, "vendor", "opencode", platformKey);
+const destBin = join(destDir, "opencode");
+mkdirSync(destDir, { recursive: true });
+
+const bytes = readFileSync(artifact);
+const hash = sha256(bytes);
+const provenance = `overlay ${manifestSha}`;
+
+writeFileSync(destBin, bytes);
+chmodSync(destBin, 0o755);
+writeFileSync(join(destDir, ".sha256"), hash + "\n");
+writeFileSync(join(destDir, ".source"), provenance + "\n");
+
+console.log(`[build:binary] installed: ${destBin}`);
+console.log(`[build:binary] sha256: ${hash}`);
+console.log(`[build:binary] source: ${provenance}`);
+
+// ── smoke test: version check ───────────────────────────────────────────────
+const ver = spawnSync(destBin, ["--version"], { encoding: "utf8", timeout: 10000 });
+if (ver.status !== 0) {
+ console.warn(`[build:binary] WARNING: version check failed (exit ${ver.status}): ${ver.stderr?.trim()}`);
+} else {
+ console.log(`[build:binary] version: ${ver.stdout.trim()}`);
+}
+
+console.log("[build:binary] DONE");