diff --git a/.agents/skills/versioned-tool/SKILL.md b/.agents/skills/versioned-tool/SKILL.md
index 572eb7f845..b2cb32ce94 100644
--- a/.agents/skills/versioned-tool/SKILL.md
+++ b/.agents/skills/versioned-tool/SKILL.md
@@ -15,11 +15,12 @@ This project manages external CLI tools through a **single-source-of-truth versi
| --- | --- | --- | --- |
| OpenCode (action default) | `DEFAULT_OPENCODE_VERSION` | `opencode-version` | _Release-job coupled — NOT Renovate (see below)_ |
| OpenCode (action fallback) | `FALLBACK_VERSION` in `src/services/setup/opencode.ts` | _(internal only)_ | `github-releases` (`anomalyco/opencode`) |
-| OpenCode (workspace image) | `ARG OPENCODE_VERSION` in `deploy/workspace.Dockerfile` | _(internal only)_ | _Release-job coupled — NOT Renovate (see below)_ |
+| OpenCode (workspace image, V1 default) | `ARG OPENCODE_VERSION` in `deploy/workspace.Dockerfile` | _(internal only)_ | _Release-job coupled — NOT Renovate (see below)_ |
+| OpenCode (workspace image, V2) | `version` in `packages/runtime/src/shared/opencode-v2-pin.json`, passed as the workspace build's `--build-arg OPENCODE_VERSION` | _(internal only)_ | `github-releases` (`anomalyco/opencode`, major 2 only; custom manager on the JSON file) |
| Systematic | `DEFAULT_SYSTEMATIC_VERSION` | `systematic-version` | `npm` (`@fro.bot/systematic`) |
| OpenCode (harness base) | `base_version` in `packages/harness/harness.config.json` | _(internal only — config-JSON, not a TS constant)_ | `github-releases` (`anomalyco/opencode`) |
-**Release-job coupled sites (not Renovate-tracked):** `DEFAULT_OPENCODE_VERSION` and the workspace `ARG OPENCODE_VERSION` hold the harness build version (`+harness.`). Renovate cannot order SemVer build-metadata tags, so both are bumped together by `harness-release.yaml`'s `sync-default-version` job at publish time (one auto-PR, dual-source idempotency guard). Do not add Renovate `customManager` entries for these — they would be dropped. See `docs/solutions/best-practices/cross-libc-build-and-release-safety-2026-06-14.md` §4 and `docs/solutions/workflow-issues/harness-base-version-source-of-truth-2026-06-12.md`.
+**Release-job coupled sites (not Renovate-tracked):** `DEFAULT_OPENCODE_VERSION` and the workspace `ARG OPENCODE_VERSION` hold the harness build version (`+harness.`); the Dockerfile ARG default stays this V1 harness build, and V2 is selected by build-arg instead. Renovate cannot order SemVer build-metadata tags, so both are bumped together by `harness-release.yaml`'s `sync-default-version` job at publish time (one auto-PR, dual-source idempotency guard). Do not add Renovate `customManager` entries for these — they would be dropped. **V2 pin:** the JSON file's `version` is Renovate-tracked, but its per-asset digests are not; after a bump, run `node scripts/sync-opencode-v2-pin.ts` and commit the result (see `deploy/README.md` § OpenCode V2). See `docs/solutions/best-practices/cross-libc-build-and-release-safety-2026-06-14.md` §4 and `docs/solutions/workflow-issues/harness-base-version-source-of-truth-2026-06-12.md`.
## Quick Start
diff --git a/.github/renovate.json5 b/.github/renovate.json5
index 0f44b6a14a..daa1447a74 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -82,6 +82,18 @@
datasourceTemplate: 'github-releases',
extractVersionTemplate: '^bun-v(?.*)$',
},
+ {
+ // Tracks the stock OpenCode V2 pin. The Dockerfile's `opencode-v2` stage and the Action's
+ // installOpenCode both read this file. Bumping `version` alone leaves the per-asset sha256
+ // digests stale, and both consumers fail closed on that: the digests are regenerated by
+ // `scripts/sync-opencode-v2-pin.ts` (see the packageRules entry below).
+ customType: 'regex',
+ managerFilePatterns: ['/packages\\/runtime\\/src\\/shared\\/opencode-v2-pin\\.json/'],
+ matchStrings: ['"version":\\s*"(?\\d+\\.\\d+\\.\\d+)"'],
+ depNameTemplate: 'anomalyco/opencode',
+ datasourceTemplate: 'github-releases',
+ extractVersionTemplate: '^v?(?.*)$',
+ },
{
// Tracks the packageManager field in package.json (bun@X.Y.Z).
// CI installs this version through .github/actions/setup; keep it in lockstep with
@@ -148,6 +160,21 @@
matchPackageNames: ['@opencode-ai/sdk', 'anomalyco/opencode'],
allowedVersions: '<=1.19.4',
},
+ {
+ // The V2 pin lives in its own file and follows its own major, so the V1 cap above does not
+ // apply to it. This entry must stay AFTER that cap (a later rule wins) and must not become the
+ // first `anomalyco/opencode` rule with `allowedVersions`: pin-invariants.test.ts reads the cap
+ // from the first one. The pin PR cannot pass CI as Renovate opens it, because the per-asset
+ // digests still describe the old version; prBodyNotes tells the reviewer how to fix that.
+ matchFileNames: ['packages/runtime/src/shared/opencode-v2-pin.json'],
+ matchPackageNames: ['anomalyco/opencode'],
+ allowedVersions: '/^2\\./',
+ commitMessageTopic: 'OpenCode V2 pin',
+ automerge: false,
+ prBodyNotes: [
+ 'This only bumps `version`. Regenerate the sha256 digests before merging: `node scripts/sync-opencode-v2-pin.ts`, then commit `packages/runtime/src/shared/opencode-v2-pin.json`. CI builds the V2 workspace image against the new pin.',
+ ],
+ },
{
// Cap Bun at 1.4.4. The build gate requires an exact match with upstream
// anomalyco/opencode's packageManager field at the harness base version.
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index fdf7a11705..9d211caf7f 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -654,6 +654,101 @@ jobs:
# watcher assertion above: nothing else asserts it, so pin it here.
docker exec "$cid" sh -c '[ "$OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS" = "true" ]'
echo "OK: clone API up on :9100, opencode present, base config (no baked model), watcher disabled, background subagents enabled, auth fail-soft"
+ - name: Build workspace Docker image (OpenCode V2)
+ run: |
+ # The V2 leg of this job. The Dockerfile selects the stock V2 binary when OPENCODE_VERSION
+ # has major 2 or higher; the only valid value is the pin, so it is read from the pin file
+ # (never hard-coded here) and bumps in one place. Every stage before the OpenCode version
+ # is shared with the V1 build above and comes from this runner's layer cache.
+ v2_version="$(node -p 'JSON.parse(fs.readFileSync("packages/runtime/src/shared/opencode-v2-pin.json", "utf8")).version')"
+ echo "V2 pin: ${v2_version}"
+ for attempt in 1 2 3; do
+ if docker build --build-arg "OPENCODE_VERSION=${v2_version}" -f deploy/workspace.Dockerfile -t fro-bot-workspace:smoke-v2 .; then
+ exit 0
+ fi
+ echo "::warning::docker build (V2) attempt ${attempt} failed"
+ [ "${attempt}" -eq 3 ] && { echo "docker build (V2) failed after 3 attempts"; exit 1; }
+ sleep $((attempt * 15))
+ done
+ - name: Smoke-test workspace image (OpenCode V2 boots through its entrypoint, no network)
+ run: |
+ set -euo pipefail
+ v2_version="$(node -p 'JSON.parse(fs.readFileSync("packages/runtime/src/shared/opencode-v2-pin.json", "utf8")).version')"
+ case "${v2_version}" in
+ [2-9].*|[1-9][0-9]*.*) : ;;
+ *) echo "opencode-v2-pin.json version is not a V2 version: ${v2_version}" >&2; exit 1 ;;
+ esac
+
+ # 1. The stock musl binary starts as the agent uid with NO network, and both plugins load
+ # from the image's cache and report active. Plugin specs come from the runtime pins, the
+ # same values the Dockerfile installs (workspace-v2-image.test.mjs pins ARG == constant).
+ systematic_version="$(node -e "const m=fs.readFileSync('packages/runtime/src/shared/constants.ts','utf8').match(/DEFAULT_SYSTEMATIC_VERSION\s*=\s*'([^']+)'/); if(!m) process.exit(1); process.stdout.write(m[1])")"
+ plugin_version="$(node -e "const m=fs.readFileSync('packages/runtime/src/shared/constants.ts','utf8').match(/FRO_BOT_PLUGIN_VERSION\s*=\s*'([^']+)'/); if(!m) process.exit(1); process.stdout.write(m[1])")"
+ docker run --rm --network none --user 10001:10001 \
+ -e HOME=/home/opencode -e XDG_CONFIG_HOME=/home/opencode/.config \
+ -e XDG_DATA_HOME=/home/opencode/.local/share -e XDG_CACHE_HOME=/home/opencode/.cache \
+ -e XDG_STATE_HOME=/home/opencode/.local/state \
+ -v "$PWD/deploy/scripts/v2-image-smoke.mjs:/smoke.mjs:ro" \
+ --entrypoint node fro-bot-workspace:smoke-v2 /smoke.mjs \
+ "@fro.bot/systematic@${systematic_version}" "@fro.bot/opencode-plugin@${plugin_version}"
+
+ # 2. The real entrypoint, with the production security posture and no network. /readyz is 200
+ # only after the supervisor's plugin gate (systematic + fro-bot.opencode-plugin active) and
+ # permission gate pass, and it echoes the version the gateway selects its V2 loop from.
+ # Readiness needs no provider and no network: the plugins are installed at image build.
+ cid="$(docker run -d --network none \
+ -e WORKSPACE_OPENCODE_TOKEN=dummy-smoke-token \
+ -e MITMPROXY_CA_WAIT_SECONDS=0 \
+ -e NO_PROXY='*' \
+ $WORKSPACE_DOCKER_SECURITY_FLAGS \
+ fro-bot-workspace:smoke-v2)"
+ cleanup() { docker rm -f "$cid" >/dev/null 2>&1 || true; }
+ trap cleanup EXIT
+
+ body=''
+ ready=false
+ for _ in $(seq 1 90); do
+ if body="$(docker exec "$cid" curl -fsS --max-time 5 http://127.0.0.1:9100/readyz 2>/dev/null)"; then
+ ready=true
+ break
+ fi
+ sleep 1
+ done
+ # Read the logs once: a `docker logs | grep -q` pipeline can SIGPIPE under pipefail.
+ logs="$(docker logs "$cid" 2>&1)"
+ echo "--- workspace logs ---"
+ echo "${logs}"
+ if [ "${ready}" != "true" ]; then
+ echo "REGRESSION: /readyz did not return 200 within 90s on the V2 image"
+ exit 1
+ fi
+ echo "/readyz: ${body}"
+ echo "${body}" | node -e '
+ const body = JSON.parse(fs.readFileSync(0, "utf8"))
+ if (body.ready !== true || body.opencode !== "ready" || body.opencodeVersion !== process.argv[1]) {
+ console.error("unexpected /readyz body for the V2 pin " + process.argv[1])
+ process.exit(1)
+ }
+ ' "${v2_version}"
+ echo "${logs}" | grep -q "opencode-server-v2: ready"
+ if echo "${logs}" | grep -q "ERR_MODULE_NOT_FOUND"; then
+ echo "REGRESSION: module resolution failed (ERR_MODULE_NOT_FOUND)"
+ exit 1
+ fi
+
+ # 3. The binary is stock V2 at the pin: no +harness build.
+ oc_version="$(docker exec "$cid" opencode --version)"
+ echo "opencode --version: ${oc_version}"
+ if echo "${oc_version}" | grep -qF "harness"; then
+ echo "REGRESSION: V2 image reports a harness build: ${oc_version}"
+ exit 1
+ fi
+ reported="${oc_version#opencode }"
+ if [ "${reported#v}" != "${v2_version}" ]; then
+ echo "REGRESSION: expected OpenCode ${v2_version}, got: ${oc_version}"
+ exit 1
+ fi
+ echo "OK: V2 image ready on ${oc_version}, plugins active, no network"
- name: Smoke-test auth provisioning (valid blob → auth.json written 0600)
run: |
set -e
diff --git a/.github/workflows/opencode-plugin-release.yaml b/.github/workflows/opencode-plugin-release.yaml
new file mode 100644
index 0000000000..327e94e781
--- /dev/null
+++ b/.github/workflows/opencode-plugin-release.yaml
@@ -0,0 +1,167 @@
+name: opencode-plugin-release
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Version to publish (for example 0.1.0 or 0.2.0-rc.1)
+ required: true
+ type: string
+ dry_run:
+ description: 'Dry run: verify and pack, but do not publish'
+ required: false
+ type: boolean
+ default: false
+
+concurrency:
+ group: opencode-plugin-release-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ name: verify (gates, build, pack)
+ runs-on: ubuntu-24.04
+
+ permissions:
+ contents: read
+
+ outputs:
+ version: ${{ steps.params.outputs.version }}
+ dry_run: ${{ steps.params.outputs.dry_run }}
+ tarball: ${{ steps.pack.outputs.tarball }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+
+ - name: Resolve version
+ id: params
+ env:
+ VERSION: ${{ inputs.version }}
+ DRY_RUN: ${{ inputs.dry_run }}
+ run: |
+ set -euo pipefail
+
+ if ! printf '%s' "${VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
+ echo "::error::'${VERSION}' is not a plain semver version"
+ exit 1
+ fi
+
+ echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
+ echo "dry_run=${DRY_RUN:-false}" >> "${GITHUB_OUTPUT}"
+
+ - name: Setup Node.js and Bun
+ uses: ./.github/actions/setup
+
+ - name: Lint, type-check and test
+ run: |
+ set -euo pipefail
+ bun run --filter @fro.bot/opencode-plugin lint
+ bun run --filter @fro.bot/opencode-plugin check-types
+ bun run --filter @fro.bot/opencode-plugin test
+
+ - name: Build
+ run: bun run --filter @fro.bot/opencode-plugin build
+
+ - name: Check the bundle only imports what the host provides
+ working-directory: packages/opencode-plugin
+ run: |
+ set -euo pipefail
+ node --input-type=module -e '
+ import {readFileSync} from "node:fs"
+ const source = readFileSync("dist/index.mjs", "utf8")
+ const patterns = [/\bfrom\s*["\x27]([^"\x27]+)["\x27]/g, /\bimport\s*["\x27]([^"\x27]+)["\x27]/g, /\b(?:import|require)\(\s*["\x27]([^"\x27]+)["\x27]\s*\)/g]
+ const specifiers = patterns.flatMap(pattern => [...source.matchAll(pattern)].map(m => m[1]))
+ const allowed = (s) => s === "effect" || s.startsWith("effect/") || s === "@opencode/plugin" || s.startsWith("@opencode/plugin/") || s.startsWith("node:")
+ const bad = [...new Set(specifiers.filter(s => !allowed(s)))]
+ if (bad.length > 0) { console.error("::error::unexpected imports in dist/index.mjs: " + bad.join(", ")); process.exit(1) }
+ if (!specifiers.includes("@opencode/plugin")) { console.error("::error::dist/index.mjs no longer imports @opencode/plugin"); process.exit(1) }
+ console.log("bundle imports ok: " + [...new Set(specifiers)].join(", "))
+ '
+
+ - name: Pack
+ id: pack
+ env:
+ VERSION: ${{ steps.params.outputs.version }}
+ run: |
+ set -euo pipefail
+ # Packed from a staging copy so the workspace manifest (workspace:* devDependencies) is never published.
+ STAGE="$(mktemp -d)"
+ PKG=packages/opencode-plugin
+ mkdir -p "${STAGE}/pkg" "${STAGE}/out"
+ cp -R "${PKG}/dist" "${PKG}/README.md" "${PKG}/LICENSE" "${PKG}/package.json" "${STAGE}/pkg/"
+ (
+ cd "${STAGE}/pkg"
+ npm pkg set "version=${VERSION}"
+ npm pkg delete devDependencies scripts
+ npm pack --pack-destination "${STAGE}/out"
+ )
+ TARBALL="$(ls "${STAGE}/out")"
+ mkdir -p dist-tarball
+ cp "${STAGE}/out/${TARBALL}" "dist-tarball/${TARBALL}"
+ echo "Packed contents:"
+ tar -tzf "dist-tarball/${TARBALL}"
+ echo "tarball=${TARBALL}" >> "${GITHUB_OUTPUT}"
+
+ - name: Upload tarball
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: opencode-plugin-tarball
+ path: dist-tarball/
+ if-no-files-found: error
+ retention-days: 7
+
+ # id-token is granted here only. This job runs none of the repository's code, so nothing in the build can obtain an
+ # OIDC token; it publishes the tarball verify produced.
+ publish:
+ name: publish (npm trusted publishing)
+ needs: verify
+ if: ${{ !cancelled() && needs.verify.result == 'success' && needs.verify.outputs.dry_run != 'true' }}
+ runs-on: ubuntu-24.04
+
+ permissions:
+ id-token: write
+ contents: read
+
+ steps:
+ - name: Download tarball
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ name: opencode-plugin-tarball
+ path: dist-tarball
+
+ - name: Setup Node.js (for npm trusted publishing via OIDC)
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
+ with:
+ node-version: '24'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Check npm supports trusted publishing
+ run: |
+ version="$(npm --version)"
+ node -e 'const [a, b, c] = process.argv[1].split(".").map(Number); process.exit(a > 11 || (a === 11 && (b > 5 || (b === 5 && c >= 1))) ? 0 : 1)' "$version" \
+ || { echo "::error::npm ${version} is older than 11.5.1, which trusted publishing requires"; exit 1; }
+
+ - name: Publish to npm (trusted publishing)
+ env:
+ VERSION: ${{ needs.verify.outputs.version }}
+ TARBALL: ${{ needs.verify.outputs.tarball }}
+ run: |
+ set -euo pipefail
+ if npm view "@fro.bot/opencode-plugin@${VERSION}" version >/dev/null 2>&1; then
+ echo "Already published: @fro.bot/opencode-plugin@${VERSION}; skipping"
+ exit 0
+ fi
+
+ case "${VERSION}" in
+ *-*) DIST_TAG="next" ;;
+ *) DIST_TAG="latest" ;;
+ esac
+
+ echo "Publishing @fro.bot/opencode-plugin@${VERSION} (dist-tag ${DIST_TAG})..."
+ npm publish "./dist-tarball/${TARBALL}" --tag "${DIST_TAG}"
diff --git a/.gitignore b/.gitignore
index faceeede60..50352091a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -113,6 +113,7 @@ eslint-typegen.d.ts
packages/runtime/dist/
packages/gateway/dist/
packages/harness/dist/
+packages/opencode-plugin/dist/
apps/action/dist/
apps/workspace-agent/dist/
diff --git a/.node-version b/.node-version
index ce62013c39..b74495a07d 100644
--- a/.node-version
+++ b/.node-version
@@ -1 +1 @@
-24.22.1
+24.22.2
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 64647cb01c..f1a1935855 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -12,7 +12,7 @@ This monorepo ships three distinct deployable surfaces from one codebase:
- **`@fro-bot/gateway`** (`packages/gateway/`) — a Discord-first daemon that listens for `@fro-bot` mentions in bound guild channels and runs OpenCode inside a sandboxed workspace container. Includes an operator web surface (Hono, gateway-net only), an inbound announce webhook, and an S3-backed coordination layer. The `/fro-bot dispatch` slash command is a separate, simpler path: it asks the bound repo's `fro-bot.yaml` workflow to run via the GitHub Actions API and returns the run URL — it never touches the gateway's queue, concurrency cap, or run-state.
- **`@fro.bot/harness`** (`packages/harness/`) — a patched OpenCode binary built via an LLM-merge integration pipeline. Published to npm and GitHub Releases; the Action setup phase downloads the checksum-verified release tarball (not the npm package) as the default OpenCode binary, and reuses an existing `opencode` only when it reports exactly the pinned version. The npm package ships `harness` and `opencode` commands (see [`packages/harness/README.md`](packages/harness/README.md)).
-Supporting packages: `@fro-bot/runtime` (`packages/runtime/`) owns shared runtime primitives and version-pin constants; `@fro-bot/action` (`apps/action/`) is a thin workspace wrapper whose build produces the committed root `dist/`; `@fro-bot/workspace-agent` (`apps/workspace-agent/`) is the Hono HTTP sidecar inside the workspace container.
+Supporting packages: `@fro-bot/runtime` (`packages/runtime/`) owns shared runtime primitives and version-pin constants; `@fro-bot/action` (`apps/action/`) is a thin workspace wrapper whose build produces the committed root `dist/`; `@fro-bot/workspace-agent` (`apps/workspace-agent/`) is the Hono HTTP sidecar inside the workspace container. `@fro.bot/opencode-plugin` (`packages/opencode-plugin/`) is the OpenCode V2 plugin published to npm: the `session_*` tools plus a guard that rejects `subagent` calls resuming a session. V2 skips a plugin that fails to load and keeps serving, so its id (`fro-bot.opencode-plugin`) is fixed and the surfaces confirm it by id; no surface loads it yet.
## Codemap
@@ -63,7 +63,8 @@ Symbols verified against the live source tree. Where a symbol has moved to `pack
| `createRemoteQuestionClient` | Function | `packages/runtime/src/agent/remote-question-client.ts` | v2-SDK `question` reply/reject client |
| `TriggerDirective` | Interface | `packages/runtime/src/agent/prompt.ts` | Directive + appendMode for triggers |
| `DEFAULT_SYSTEMATIC_VERSION` | Constant | `packages/runtime/src/shared/constants.ts` | Pinned Systematic version |
-| `DEFAULT_OPENCODE_VERSION` | Constant | `packages/runtime/src/shared/constants.ts` | Pinned harness build version |
+| `DEFAULT_OPENCODE_VERSION` | Constant | `packages/runtime/src/shared/constants.ts` | Pinned harness build (Action default; not the workspace image's) |
+| `opencode-v2-pin.json` | JSON | `packages/runtime/src/shared/opencode-v2-pin.json` | Stock V2 version + asset sha256 |
### `packages/gateway/`
@@ -300,7 +301,8 @@ Per-repo S3 conditional-write locks (`If-None-Match` / `If-Match`) built by `get
- Root `session.idle` with a non-empty ledger enters drain. The drain-completion gate (`execute/drain-completion.ts`) requires each child's notice, current-generation root idle, and a REST-confirmed terminal root reply. Reconciliation settles tracked entries only; it never adopts.
- Teardown settles through `settleOwnedSessions`; an unconfirmed root or child means `quarantined` (held lock, no slot hand-off).
-- `deploy/plugins/no-task-reuse.mjs` rejects any `task` call with a non-empty `task_id`, so each background job has its own child session.
+- `deploy/plugins/no-task-reuse.mjs` rejects any `task` call with a non-empty `task_id`, so each background job has its own child session. On V2 the `@fro.bot/opencode-plugin` guard does this.
+- Each run picks its loop from the workspace's `/readyz` `opencodeVersion`: V1 `run-core.ts`, V2 `run-core-v2.ts` (with the `-v2` drain and settle modules).
> See also: [Background Subagents and Ownership](docs/wiki/Background%20Subagents%20and%20Ownership.md#background-subagent-ownership-ledger-gateway)
diff --git a/STRUCTURE.md b/STRUCTURE.md
index b698a85e01..04f0984d47 100644
--- a/STRUCTURE.md
+++ b/STRUCTURE.md
@@ -42,6 +42,7 @@ fro-bot/agent/
│ │ ├── operator-contract/ # Operator contract types
│ │ └── redaction/ # PII/secret redaction gate
│ ├── harness/ # @fro.bot/harness — patched-OpenCode build + publish pipeline
+│ ├── opencode-plugin/ # @fro.bot/opencode-plugin — OpenCode V2 plugin (session tools + subagent reuse guard)
│ └── runtime/ # @fro-bot/runtime — shared runtime primitives + version-pin constants
│ └── src/
│ ├── shared/ # Logger, types, constants (owns real version pins)
@@ -94,12 +95,13 @@ fro-bot/agent/
- **`apps/workspace-agent/`** — Hono HTTP service that runs inside the workspace container (clone setup with staged root→agent ownership handoff, read-only `POST /inspect` checkout observation, `POST /update` checkout fast-forward, `POST /recover/preview`+`/recover` quarantine-and-replace, `GET`/`DELETE /backups` generations, bearer-gated control API, per-repo mutex and journal store, checkout-update git primitives, supervised unprivileged OpenCode); touch when adding workspace-side API endpoints. Serialize every mutating repo operation through `src/repo-mutex.ts` (also the sticky, restart-only maintenance hold) and record in-flight update/recovery state in `src/journal.ts`; real-git adversarial fixtures live in `src/update-fixtures/`. Import uid/gid/path constants from `src/identity.ts` (mirrored in `deploy/workspace.Dockerfile`) and run any git against an existing checkout through `src/git-safety.ts` as the agent uid — never as the root service. Checkout size/entry counts go through `src/agent-walk.ts`'s agent-uid or sealed-fd walker, never a root-owned path lookup. Checkout types in `src/types.ts` are mirrored in `packages/gateway/src/workspace-api/types.ts` and pinned by `scripts/checkout-types-drift-guard.test.ts` — change both sides together.
- **`packages/gateway/`** — Discord-first daemon and operator web surface; the largest package, containing the mention loop, command handlers, approval gate, redaction pipeline, and the `web/ingress/` trusted-proxy client-address resolver that keys the unauthenticated operator-surface rate limits and OAuth bindings.
- **`packages/harness/`** — Patched-OpenCode build and publish pipeline; touch when updating the bundled OpenCode binary.
+- **`packages/opencode-plugin/`** — Fro Bot's OpenCode V2 plugin, published to npm: the `session_*` tools and the `subagent` session-reuse guard, bundled with the V2 session code from `@fro-bot/runtime/session-v2`; touch when changing what the plugin gives the agent. The V2 workspace image installs it from npm at build time and loads it at each OpenCode spawn.
- **`packages/runtime/`** — Shared runtime primitives consumed by both `src/` and `packages/gateway/`; owns the authoritative version-pin constants.
- **`deploy/`** — Docker Compose stack, Dockerfiles, mitmproxy egress topology, and deploy validation scripts.
- **`deploy/plugins/`**, **`deploy/managed-config/`** — the baked OpenCode reuse-guard plugin (`no-task-reuse.mjs`) and the root-owned managed config (`/etc/opencode/opencode.json`) that loads it; plain Node ESM / JSON, tested from `deploy/scripts/*.test.mjs`. The same `no-task-reuse.mjs` is the single source for the Action's guard: `tsdown.config.ts` builds it into `dist/no-task-reuse.js`, and `src/services/setup/no-task-reuse-config.ts` copies it into the CI OpenCode config dir at every Action server start (`no-task-reuse.d.mts` types it for the Vitest suite).
-- **`deploy/scripts/`** — Plain Node ESM (`.mjs`) helpers for deploy-time operations (auth validation, config merge, protected-dir creation, legacy checkout ownership migration, agent-side config provisioning); uses `node --test`, not Vitest.
+- **`deploy/scripts/`** — Plain Node ESM (`.mjs`) helpers for deploy-time operations (auth validation, config merge, protected-dir creation, legacy checkout ownership migration, agent-side config provisioning, V2 plugin install and image smoke); uses `node --test`, not Vitest.
- **`deploy/tests/`** — `isolation-harness.sh`, the CI acceptance harness that proves the service/agent uid boundary in a real container started with production's security posture.
-- **`scripts/`** — Repo-level build tooling: action dist builder, hidden-Unicode scrubber, third-party notices, release dispatch.
+- **`scripts/`** — Repo-level build tooling: action dist builder, hidden-Unicode scrubber, third-party notices, release dispatch, and `sync-opencode-v2-pin.ts` (regenerates the OpenCode V2 pin's digests on a Renovate bump).
- **`evals/`** — Gated agent-outcome eval corpus that runs the real execution path against disposable fixture repos; the pure gate and baseline tests run in normal CI, while live scenarios require `FRO_BOT_EVAL=1`.
- **`.github/workflows/`** — All CI/CD automation; 12 workflow files covering tests, releases, security scanning, and bot triggers.
- **`RFCs/`** — 19 architecture specification documents; read before making cross-cutting changes.
@@ -118,6 +120,7 @@ fro-bot/agent/
| `@fro-bot/workspace-agent` | `apps/workspace-agent/` | Hono HTTP service in the workspace container |
| `@fro-bot/gateway` | `packages/gateway/` | Discord daemon, operator web surface, mention loop, redaction |
| `@fro.bot/harness` | `packages/harness/` | Patched-OpenCode build and publish pipeline; ships the `harness` and `opencode` commands |
+| `@fro.bot/opencode-plugin` | `packages/opencode-plugin/` | OpenCode V2 plugin: session tools and the subagent reuse guard; the `fro-bot.opencode-plugin` id |
| `@fro-bot/runtime` | `packages/runtime/` | Shared runtime primitives; owns authoritative version-pin constants |
### CI Workflows
@@ -131,6 +134,7 @@ fro-bot/agent/
| `fro-bot.yaml` | `issue_comment`, `issues`, `schedule`, `workflow_dispatch` | Fro Bot agent invocation (the Action under development) |
| `harness-integrate.yaml` | `workflow_call` | LLM-merge integration of OpenCode refs into the harness build; called by `harness-release.yaml` |
| `harness-release.yaml` | `push (harness-v* tags), workflow_dispatch` | Build matrix and publish for `@fro.bot/harness` |
+| `opencode-plugin-release.yaml` | `workflow_dispatch` | Verify, pack and publish `@fro.bot/opencode-plugin` (npm trusted publishing) |
| `osv-scanner.yaml` | `merge_group`, `pull_request`, `push`, `schedule` | PR-differential vulnerability enforcement plus report-only full scans for merge queue/main/scheduled runs |
| `prepare-release-pr.yaml` | `schedule`, `workflow_dispatch` | Opens release PR via semantic-release |
| `renovate.yaml` | `issues`, `pull_request`, `push`, `workflow_dispatch`, `workflow_run` | Renovate dependency update automation |
diff --git a/apps/workspace-agent/AGENTS.md b/apps/workspace-agent/AGENTS.md
index 98e66a7acb..09bfe26de8 100644
--- a/apps/workspace-agent/AGENTS.md
+++ b/apps/workspace-agent/AGENTS.md
@@ -37,7 +37,7 @@ Exposes `POST /clone` (clones a GitHub repo into `/workspace/repos/{owner}/{repo
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /healthz | none | Liveness probe — returns `{ok: true}` |
-| GET | /readyz | none | Readiness probe — gates on OpenCode + proxy state |
+| GET | /readyz | none | Readiness probe — gates on OpenCode + proxy state; echoes `opencodeVersion` (the gateway picks its V1 or V2 loop from it) |
| POST | /clone | bearer | Clone a GitHub repo into the workspace |
| POST | /inspect | bearer | Read-only observation of an existing checkout |
| POST | /update | bearer | Admission, bare-repo fetch, pack-stream import, journaled fast-forward of an existing checkout |
@@ -137,6 +137,10 @@ src/
├── recover.ts /recover/preview and /recover: fingerprint preview, quarantine-and-replace mutation
├── backups.ts /backups list/delete; the quarantine-generation metadata schema (including the optional run id)
├── opencode-instance.ts Best-effort POST /instance/dispose to the loopback OpenCode, run after a successful /recover
+├── opencode-generation.ts Reads WORKSPACE_IMAGE_OPENCODE_VERSION (baked by the image); major 2+ selects the V2 trio below
+├── opencode-server-v2.ts V2 supervisor: allowlisted env, fresh database per spawn, credential holder, plugin + permission gates
+├── opencode-proxy-v2.ts V2 proxy: gateway bearer in, the ready server's Basic credential out, upstream 401 reported as such
+├── opencode-instance-v2.ts V2 best-effort location evict (DELETE /api/debug/location), authenticated as the running server
├── checkout-profile.ts Config inventory, layout, and temp-index cleanliness admission checks shared by update/recover
├── git-safety.ts Sealed, credential-free git invocation profiles for git against an EXISTING checkout
├── git-stream.ts pack-objects | index-pack streaming primitive (runPackStream)
@@ -150,6 +154,17 @@ src/
└── types.ts Request/response types (shared contract with gateway workspace-api)
```
+## OpenCode V2 (stock 2.x images)
+
+`deploy/workspace.Dockerfile` bakes `WORKSPACE_IMAGE_OPENCODE_VERSION` (the `OPENCODE_VERSION` that chose the binary). `main.ts` reads it through `readOpencodeGeneration`: major 2 or higher injects the V2 runner, proxy and disposer; unset or older keeps the V1 ones exactly. A value that is set but is not a version exits 1 before any bind. The same value is reported as `opencodeVersion` on `/healthz` and `/readyz`, and the gateway picks its run loop from it. The image pin is the only switch; there is no operator setting.
+
+- **Spawn** goes through `startOpenCodeV2Server` (`@fro-bot/runtime/workspace-v2`): `serve --stdio` with a held stdin, an explicit port, a per-spawn Basic password, readiness on `/api/agent`, and the plugin gate (Systematic `systematic` and `fro-bot.opencode-plugin` must be `active`). The child runs as uid/gid 10001 with an environment built from an allowlist.
+- **Plugins are installed at image build time** by `deploy/scripts/install-opencode-v2-plugins.sh` (bounded, as uid 10001) and listed in the config content passed at spawn. Nothing installs at boot.
+- **Every spawn gets a fresh database** (`prepareFreshDatabase`, under the root-owned `/var/lib/workspace-agent/opencode-v2-db`), so V2 has no execution claim to resume after a restart.
+- **The credential holder** is written before readiness flips to ready and cleared on any exit, so the proxy answers 503 (never forwards a stale password) between servers. An upstream 401 is reported as a 502 with `x-fro-bot-proxy-error: upstream-auth`.
+- **Shutdown** lists the sessions still active, interrupts them (reason `user`), closes stdin and confirms the exit, inside the 25s drain. `main.ts` waits for the V2 supervisor before it closes the proxy.
+- **Permission gate:** after the plugin gate the supervisor checks the effective rules from `/api/agent` (`ask` or `deny` outside the checkout, `session_move` and `worktree_create` denied). A server that fails never serves.
+
## Build
```bash
@@ -161,7 +176,7 @@ bun run --filter @fro-bot/workspace-agent check-types
## Deployment
-`deploy/workspace.Dockerfile` builds this package and bakes the OpenCode CLI (musl build, pinned to `DEFAULT_OPENCODE_VERSION`) plus a base `@fro.bot/systematic` plugin config. No model or provider is baked — those are deploy-time config. The container launches via `deploy/workspace-entrypoint.sh`, which (1) installs the mitmproxy CA into the **system** trust store (`update-ca-certificates`) — `git` and the `opencode` binary read the system CA bundle, so this is required for egress through the proxy; (2) validates the optional `workspace-opencode-auth` secret (API-key `auth.json` blob) and writes it to OpenCode's data path (`$XDG_DATA_HOME/opencode/auth.json`, `0600`) so the mention-loop agent can authenticate — written as a file, never exported as env, so the clone subprocess never sees it; and (3) overlays `WORKSPACE_OPENCODE_MODEL` (the `provider/model` string) and `WORKSPACE_OPENCODE_CONFIG` (a JSON `provider` block, e.g. a cliproxyapi `baseURL`) onto the base `opencode.json`, mirroring the action's `model` + `opencode-config` inputs (the Systematic plugin is always preserved). Auth and config are fail-soft when absent (clone-only boot) and fail fast when malformed. The supervisor (`main.ts`) then runs the clone API (9100), the loopback OpenCode server (54321), and the bearer proxy (9200).
+`deploy/workspace.Dockerfile` builds this package and bakes the OpenCode CLI (musl build) plus a base config. A V1 image runs the harness build (`ARG OPENCODE_VERSION`, in step with `DEFAULT_OPENCODE_VERSION`) with a base config that lists `@fro.bot/systematic`. A V2 image is selected by building with `--build-arg OPENCODE_VERSION=`: it runs the stock binary verified against that pin, installs `@fro.bot/systematic` and `@fro.bot/opencode-plugin` at build time, and its base config is `{"update":"disable"}` with no plugin entry (the supervisor passes the plugins as config content at each spawn; see [deploy/README.md](../../deploy/README.md#opencode-v2)). `WORKSPACE_IMAGE_OPENCODE_VERSION` carries the version to `main.ts`, which picks the V1 or V2 supervisor. No model or provider is baked — those are deploy-time config. The container launches via `deploy/workspace-entrypoint.sh`, which (1) installs the mitmproxy CA into the **system** trust store (`update-ca-certificates`) — `git` and the `opencode` binary read the system CA bundle, so this is required for egress through the proxy; (2) validates the optional `workspace-opencode-auth` secret (API-key `auth.json` blob) and writes it to OpenCode's data path (`$XDG_DATA_HOME/opencode/auth.json`, `0600`) so the mention-loop agent can authenticate — written as a file, never exported as env, so the clone subprocess never sees it; and (3) overlays `WORKSPACE_OPENCODE_MODEL` (the `provider/model` string) and `WORKSPACE_OPENCODE_CONFIG` (a JSON `provider` block, e.g. a cliproxyapi `baseURL`) onto the base `opencode.json`, mirroring the action's `model` + `opencode-config` inputs (plugin entries already in the base are preserved; the V1 base lists Systematic, the V2 base lists none). Auth and config are fail-soft when absent (clone-only boot) and fail fast when malformed. The supervisor (`main.ts`) then runs the clone API (9100), the loopback OpenCode server (54321), and the bearer proxy (9200).
Steps (2) and (3) are implemented by two checked-in Node ESM helpers in `deploy/scripts/` — `validate-auth.mjs` (`validateAuth`) and `merge-config.mjs` (`mergeConfig`) — which the entrypoint invokes (`node "$SCRIPTS_DIR/