Skip to content

feat: complete the Unifia rebrand and repair the desync it left behind - #23

Merged
Rwanbt merged 480 commits into
devfrom
feat/unifia-rebrand-complete
Aug 11, 2026
Merged

feat: complete the Unifia rebrand and repair the desync it left behind#23
Rwanbt merged 480 commits into
devfrom
feat/unifia-rebrand-complete

Conversation

@Rwanbt

@Rwanbt Rwanbt commented Aug 10, 2026

Copy link
Copy Markdown
Owner

@

Issue for this PR

Closes #

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Brings the Unifia rebrand to a working state and repairs what it broke.

The rebrand repeatedly renamed one side of a pair and left the other. None of it
failed to compile: an unsubstituted define is still a valid global for
typeof, truthy() returns false for a name nobody sets, Config.withDefault
supplies the default, and ?? falls through. Every instance was silent, which
is why so much of it survived until now.

  • The sidecar died before serving: UNIFIA_MIGRATIONS was injected but
    OPENCODE_MIGRATIONS was read, so a compiled binary fell back to scanning a
    directory that does not exist inside it. This is the "cannot reach the local
    server" both desktop shells reported.
  • Electron shipped with no backend at all: nothing staged the sidecar outside
    CI, and the extraResources filter then matched zero files without warning.
  • opencode.json stopped being read, so project config, MCP servers, agents and
    permissions in that file were silently ignored. MIGRATION-PLAN.md 4.2 asks for
    the legacy name to keep working.
  • Custom tools with npm dependencies could not install: @unifia/plugin is
    injected into every tool manifest and is unpublished, and one unresolvable
    spec aborts the whole tree — while Npm.install swallowed the error.
  • The internal prompt-cache marker was written under one name and stripped under
    another, so it reached the provider on every compaction summary.
  • 17 documented UNIFIA_* settings did nothing, including the one the Windows
    CI job uses to disable the file watcher, plus 21 consumer sites still reading
    the legacy export.
  • Android: the secure-auth key had no recovery path, so a keystore invalidation
    bricked the app for good; the review and file-tree toggles were hidden below
    768px while the review panel opened by default, leaving the conversation
    unreachable.
  • CI: android.yml cloned llama.cpp unpinned and has been red since 2026-07-28;
    storybook-solidjs-vite was an unpinned range that moved to a release whose
    Solid detection does not see a Bun-hoisted dependency. Both pinned.

How did you verify your code works?

  • Full packages/opencode suite: 4188 pass, 10 skip, 0 fail (4149 / 39
    before).
  • typecheck 35/35, brand:check 30/30 + 208/208, identity:check 7/7.
  • Launched Tauri, Electron and Android for real: child sidecar alive, health
    endpoint answering, session created and persisted, 401 on a wrong password.
  • Android verified on a physical Mi 10 Pro. The secure-auth fix was confirmed by
    the wrapped key being regenerated on disk, not by reading the code.
  • Tool gating checked per client (cli / desktop / mobile-embedded / acp) against
    the live mobile sidecar over /experimental/tool/ids.
  • Every test failure left standing was proven pre-existing by running the same
    files against the pre-branch commit, rather than assumed.

Not verified: the Nix install and fixup paths (CI only evaluates, never builds),
and the storybook build past the point of the fixed error — it then hits a V8
heap limit on my machine.

Screenshots / recordings

Android, before and after: the app opened straight into the review panel with no
control able to close it; it now opens on the chat with the five-button toolbar
restored (server status, terminal, review, file tree, overflow).

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR
    @

Rwanbt added 30 commits August 3, 2026 16:05
MM2-B02-WORKER added 12 commits August 10, 2026 15:37
`matchAll(/\((?:[^()\]|\.)*\)\s*Tj/g)` restarts a failed attempt at start+1,
so a document made of `(\(\(\(...` has every other offset re-consume the whole
tail. Measured on the old code: 10 ms at 2k pairs, 603 ms at 16k — quadratic,
on bytes that come straight from an uploaded file (CodeQL
`js/polynomial-redos`). The scanner resumes where the failed attempt stopped:
0.43 ms at 16k.

Differential fuzzing over 500k random inputs found exactly one divergence
class, and it is a fix: JS `.` excludes line terminators, so `\.` could never
match a backslash-newline pair and the old regex silently dropped any run whose
literal used a line continuation. PDF 32000-1 §7.3.4.2 allows those. Both the
continuation and a 20k-pair timing guard are now covered by tests.

Affects: PDF preview text extraction only.
Does not affect: the active-element refusal, which runs before this and is
unchanged.
…n-open

Checking `isFile()` on a path and then reading that path is a TOCTOU race
(CodeQL `js/file-system-race`). Opening once and using the handle for both
assertions keeps the "it is a regular file" check without re-resolving.
`formatMs`, `diffColor` and the `ProviderID`/`ModelID` import have no callers.
Deleted rather than underscore-prefixed — git log -S recovers them if they are
ever wanted, and `diffColor` would need rewriting anyway: its nested ternary
returns a boolean when `betterLower` is true instead of a colour token.
`packages/contracts` is consumed by projects on moduleResolution node16, where
a relative import without an extension is TS2835. Checking the package's own
tsconfig in isolation passed and hid it — @unifia/workspace-runtime is what
compiles those files under node16, and it broke both the typecheck workflow and
the quality/typecheck leg of the conformance gate.
…mised

The daemon's header says it routes "whitelisted toolchain commands", but no
allowlist existed: `MUTATING` only chose whether to push sources back, so any
POST to /exec ran arbitrary shell on the developer's PC (CodeQL
`js/command-line-injection`, open since 2026-05-05). 127.0.0.1 is not the
barrier it appears to be — `adb reverse` exposes the port to the device by
design, and a page loaded in the developer's browser can POST to it with
Content-Type text/plain, which needs no preflight.

Three guards, matching the documented contract:
- the leading token must be in ALLOWED_TOOLS and the command must carry no
  shell control character, so nothing can be chained past it
- env may not set PATH, LD_*, DYLD_*, NODE_OPTIONS, BASH_ENV, SHELL or IFS,
  which would otherwise redirect which binary `cargo` resolves to
- requests carrying Origin or Sec-Fetch-* are refused and the content type must
  be application/json, which together no browser can satisfy

Tests run the real daemon: each guard refuses before anything is spawned, and
a legitimate `cargo build --release` still reaches the deviceCwd check.

Affects: script/cargo-proxy.mjs only — it has no caller in this repository.
Does not affect: the adb pull/push paths or the response shape.
After renaming the GitHub repository from Rwanbt/opencode to Rwanbt/unifia,
update all internal references to point to the new canonical URL.

Excluded intentionally (kept as historical/contextual references):
- CHANGELOG.md, docs/autonomy/EXECUTION-LOG.jsonl, docs/autonomy/reports/*:
  dated before the rename, baseline SHA 207ff45 lives on the old URL
- tauri.beta.conf.json: Rwanbt/opencode-beta is a separate repo
- packages/mobile/.../opencode-cli.js: compiled bundles, regenerated from
  updated source (FORK_REPO in installation/index.ts) on next build

Also fixes a pre-existing biome noUselessFragments warning in
packages/app/src/components/settings-audio.tsx:251 introduced by
commit a37f511 (refactor: rename the npm scope to @UNiFia).

Validated before commit:
- JSON/YAML/XML syntax intact (package.json, tauri.prod.conf.json,
  UPSTREAM-SOURCES.lock.json, 3 workflow files, appstream.metainfo.xml)
- install script: bash syntax, balanced backticks, UNIFIA_RELEASE_REPO var
- Workflow gates updated to github.repository == 'Rwanbt/unifia'
- Typecheck packages/opencode + packages/app: 0 new errors
  (1 pre-existing TS2589 in src/config/config.ts:617 unrelated)
- biome check --staged: 0 issues (3 pre-existing branch warnings on
  settings-observability.tsx + orchestrator.ts, not from this commit)
…etch

Two guards stood in front of this fetch — a regex anchored to
https://github.com/user-attachments/ and a hostname equality check — and a
comment written specifically to make CodeQL recognise the second as a barrier.
CodeQL kept flagging it (`js/request-forgery`, open since 2026-07-11), and it
was right to: it tracks the URL object into fetch, not the property reads.

Looking properly turned up a real hole behind it. The regex sees the raw
string, so `https://github.com/user-attachments/../../settings/tokens` passes
it, and `new URL` resolves that to `/settings/tokens` — the App token went
wherever on github.com an attacker's comment pointed. Verified by probe, not
by reading.

`resolveGithubAttachmentUrl` now rebuilds the URL on a literal host and checks
the prefix on the normalised path. The rebuild assigns `.pathname` rather than
using `new URL(pathname, base)`: measured, that alternative re-reads a pathname
of `//evil.example/x` — the shape `new URL()` yields from
`https://github.com//evil.example/x` — as an authority.

Extracted to module scope so it is testable at all; it was buried in a closure
inside the run handler. `sanitizeForLog` came out with it, unchanged.

Also replaces `url.replace(/\/+$/, "")` in remote-connect with a backward walk
(`js/polynomial-redos`, high, open since 2026-04-20) — same class as the
contracts fix earlier in this branch.

Affects: attachment download in the GitHub agent, remote pre-flight URL
trimming.
Does not affect: the comment-matching regexes or the imgData shape.
…stop overclaiming

Self-review of the previous commit. Two things were wrong with it.

The env denylist covered the loader and shell variables but not the ones that
matter most for a *cargo* proxy: RUSTC, RUSTC_WRAPPER,
CARGO_BUILD_RUSTC_WRAPPER and CARGO_TARGET_<triple>_RUNNER each name a program
cargo will go on to execute, and a device payload has no reason to set any of
them.

And the header comment implied the allowlist was a code-execution boundary. It
is not, and saying so invites someone to lean on it: `cargo build` runs build.rs
by design, so every permitted tool executes project code. `refuseCaller` is the
boundary; the allowlist only stops a caller naming a different program. The
comment now says which is which.
…g it

Extracting the control-character strip into `sanitizeForLog` turned one
unrecognised log-injection sanitizer into two NEW CodeQL alerts (373, 374):
the taint tracker does not follow a barrier through a helper call, so both
console.error sites became sinks and the PR check went red.

Teaching the tracker to trust the helper is the wrong fix. Neither message
needs the URL: the position in the match list says which attachment, and the
reason or HTTP status says what happened. A number cannot carry a newline, so
CWE-117 is gone by construction instead of by a sanitizer someone has to keep
convincing an analyser about. `sanitizeForLog` had no other caller and is
deleted with its test.

Also removes three dead declarations CodeQL surfaced in files this branch
touched — `stat` in install-coexistence (my own leftover, replaced by the file
handle) and `mockPort`/`mockServer` in android-pty, which were already dead
before this branch — and records why `waitFor` stays silent on timeout: every
call site asserts the same condition on the next line, so the expect gives the
real message and no test can pass vacuously.
MM2-B02-WORKER
@
ci(mobile): bust the cargo cache the repository rename invalidated

`runtime.rs unit tests` went red again after the opencode -> unifia rename, with
"failed to read plugin permissions: failed to read file
/home/runner/work/opencode/opencode/packages/mobile/src-tauri/target/debug/build/
tauri-.../out/permissions/app/autogenerated/commands/app_hide.toml".

Not a code failure. Actions caches are keyed to the repository id rather than
its name, so the rename kept them, and cargo bakes absolute paths into
build-script output under target/. The restored cache pointed at the old
workspace path while the checkout now lives at /home/runner/work/unifia/unifia.

Adding an `-r2` marker to both key and restore-keys drops every pre-rename
entry. mobile-runtime-tests is the only workflow caching a Rust target/
directory; the turbo, apt and playwright caches carry no absolute build output,
and the Swatinem cache in the android job is already green.
@
MM2-B02-WORKER
@
test(pty): wait on conditions instead of fixed sleeps in android-pty

`unit (windows)` alternated pass and fail across commits that could not have
affected it — including one that only edited a workflow cache key. Every wait in
this file was a bare `sleep()` covering a TCP connect, a JSON handshake and a
response parse. 200 ms is generous on an idle machine and short on a loaded
Windows runner, so the spawn test read pid -1 instead of 1234.

Ten waits now poll for the state each assertion is about, with a 5 s ceiling.
The helper returns rather than throwing on timeout, so the assertion that
follows still produces the real diagnostic instead of a generic timeout.

Also faster, not just steadier: the file runs in 2.6 s once warm, against the
~3.5 s of fixed sleeping it used to spend regardless. 10 pass, 0 fail on three
consecutive local runs.
@
`e2e (linux)` has been red on every commit of this branch. The cause was not
PATH resolution, which is what my previous commit assumed and what the comment
it left behind asserted: the cwd handed to Bun.spawn was
`packages/unifia`, a directory that does not exist. posix_spawn reports a
missing cwd as ENOENT *on the executable*, so the failure read
`ENOENT: posix_spawn 'bun'` and looked like a missing interpreter. That comment
is now corrected rather than left standing.

Grepping the pattern found the same edit in `e2e/backend.ts` and, in both
directions, in publish.yml. Ground truth from build.ts: pkg.name is `unifia`,
so the layout is `packages/opencode/dist/unifia-<os>-<arch>/bin/unifia`. The
signing job asked for `packages\unifia\dist` (directory renamed, artifacts not)
while the uploads asked for `dist/opencode-*` (artifacts renamed, directory
not). Neither half matches what the build writes.

The uploads now set `if-no-files-found: error`. That default — warn — is why
this was invisible: a release would have published empty CLI artifacts and
reported success. Both e2e sites likewise fail with a sentence naming the
directory instead of a misdirection.

Affects: e2e backend startup, CLI artifact upload and Windows CLI signing.
Does not affect: the build itself, which was always writing the right layout.
Untested: the publish workflow, which has no PR trigger — reviewed against
build.ts, not observed running.
Comment thread packages/unifia/test/pty/android-pty.test.ts Fixed
Comment thread packages/unifia/test/pty/android-pty.test.ts Fixed

export function activate(context: vscode.ExtensionContext) {
let openNewTerminalDisposable = vscode.commands.registerCommand("opencode.openNewTerminal", async () => {
let openNewTerminalDisposable = vscode.commands.registerCommand("unifia.openNewTerminal", async () => {
Comment thread packages/opencode/test/cli/install-coexistence.test.ts Fixed
Comment thread packages/opencode/src/cli/cmd/github-run.ts Fixed
Comment thread packages/opencode/src/cli/cmd/github-run.ts Fixed
…nbook 2026-08-10) (#25)

* feat(npm): make the @UNiFia scope publishable

`@unifia/plugin` is injected into the package.json of every config directory
the CLI manages, pinned to the running CLI version, so the scope has to resolve
on npm. Three things stopped any of it from being installable:

- `@unifia/sdk-shared` was `private: true`, had no build and no publish script,
  while both `@unifia/sdk` and `@unifia/plugin` depend on it.
- All three declared `files: ["dist"]` against an `exports` map pointing at
  `./src/*.ts`. The publish scripts rewrote the map in place, but left it
  rewritten on failure and emitted `import` ahead of `types`.
- `tsc` is incremental here (composite). With a stale tsconfig.tsbuildinfo it
  reports success while emitting nothing, so `rm -rf dist && tsc` could produce
  an empty tarball. The tsbuildinfo now goes with dist, via a `compile` script.

The export rewrite moves to `@unifia/script/npm`, shared by the three publish
scripts, and restores the manifest byte-for-byte in a `finally`. Adds
`script/publish-npm.ts` to publish the libraries alone, in dependency order,
with UNIFIA_NPM_DRY_RUN for a pack-only run.

Each package gains a README, a LICENSE and the repository/homepage/bugs
metadata an npm page needs. `@types/node` is declared an optional peer of the
plugin: its `$` declarations name `Buffer` and `BufferEncoding`.

Verified against a local Verdaccio: publish in order, then a clean project
outside the workspace resolves `@unifia/plugin@1.3.15` through npm's own
installer, typechecks strict with skipLibCheck off, and imports at runtime.

* feat(release): cut the CLI's distribution loose from upstream

The fork already had its own release pipeline (fork-release.yml, gated on
Rwanbt/unifia) while upstream's publish.yml is gated on anomalyco/opencode and
cannot fire here. What was missing was npm, and what was left behind was a set
of paths still resolving to upstream.

Distribution now: GitHub Releases + the install script, the desktop updaters,
the Android APK, npm, and a container on ghcr.io/<owner>/unifia. Homebrew,
Chocolatey, Scoop, the AUR and the Zed extension are removed rather than
re-pointed — publishing to none of them, detecting them only produced upgrade
offers computed from someone else's version line.

Removed a release-triggered path that reached a third party: no repository gate
on sync-zed-extension.yml, so publishing a release here opened a pull request
on zed-industries/extensions carrying a manifest whose download URLs pointed at
anomalyco/opencode.

Upgrade path, all in installation/index.ts:
- the npm check read upstream's `opencode-ai` and then installed `unifia-ai` at
  whatever version that returned. Upstream is at 1.18.16 against this fork's
  1.3.15, so every npm install was told to upgrade to a release that does not
  exist.
- `unifia upgrade` on a curl install fetched and ran https://opencode.ai/install
  — upstream's installer, on a domain this fork does not control — replacing
  Unifia with the upstream product. It now runs the installer this repo ships as
  a release asset.
- curl installs were detected by `.opencode/bin` only, while `install` deploys
  to `.unifia/bin`, so a current install fell through to "unknown" and had no
  upgrade method at all.

packages/opencode/script/publish-npm.ts publishes the 12 platform packages and
the unifia-ai stub, reusing the binaries build-cli already cross-compiles on one
Linux runner. Both inherited publish scripts now refuse to run without
UNIFIA_ALLOW_UPSTREAM_PUBLISH, so a manual invocation cannot push to ghcr.io/
anomalyco, the AUR or upstream's Homebrew tap; they are otherwise untouched, so
the monthly upstream sync stays conflict-free.

Also: the Linux .deb registered its AppStream metadata as
ai.opencode.opencode.metainfo.xml while the file declares ai.unifia.desktop —
both an upstream identity and a spec violation, since the filename must match
the component id. Beta updater pointed at the pre-rename Rwanbt/opencode-beta.
Nix homepages and the installer's closing link pointed at opencode.ai.

Verified: typecheck clean, 7/7 installation tests (three of them new, asserting
no code path reaches upstream's registries), 185/185 CLI tests, both guards
fire, and publish-npm.ts dry-runs idempotently against a real build.

* fix(release): derive the version from unifia-ai, not upstream's opencode-ai

A release cut without UNIFIA_VERSION read upstream's latest npm version and
bumped that. Upstream is at 1.18.16 against this product's 1.3.x, so the next
tag would have jumped an entire version line that is not ours.

* refactor(release): name the product, not what it descends from

Unifia released as `fork-release.yml` on `v*-fork*` tags with
UNIFIA_CHANNEL=fork. That is the naming a fork gives itself; nothing downstream
required it — Installation.isPreview(), the only reader of the channel, has no
callers. The workflow is now `release.yml` on `v*` (still matching the
`v*-fork*` tags cut before this) with the channel on `latest`, which is also
what `npm i -g unifia-ai` resolves.

Deleted the stub it collides with: a second `release.yml`, added with the
Docker scaffolding and never wired to a build, fired on `v*.*.*` with no
repository gate and published an empty draft from `dist/*` and `*.tar.gz` at
the repo root — paths nothing produces.

`opencode.yml` becomes `unifia.yml` and runs the composite action this repo
ships in github/ rather than `anomalyco/opencode/github@<sha>`: every `/unifia`
comment was executing upstream's agent at a revision this fork does not
control. Trigger words follow (`/uni`, `/unifia`). The `opencode/claude-opus-4-5`
model stays — `opencode` there is a provider id (provider/schema.ts:27), not
branding.

Cleared the `@unifia-ai` spelling left by an intermediate rebrand pass. One of
them was load-bearing: file-viewer.spec.ts asserted the file viewer shows
`"@unifia-ai/app"` while the package is `@unifia/app`, so the assertion could
never have matched.

Verified: typecheck clean, 148/148 config and installation tests.

* feat(config): read `.opencode`, write `.unifia`

The project config directory — skills, themes, agents, commands, plugins,
learnings, plans — was `.opencode` on both the read and the write side. That is
the same directory the separately-installed OpenCode uses, so this product was
loading the other one's project configuration and, through installDependencies
on every config load, writing a package.json, a .gitignore and a node_modules
tree into it.

Reads now cover both names, writes only `.unifia`. The merge order is the rule
projectFiles() already applies to filenames — legacy first, current brand last
so it wins where both exist — extended to the directory name, which is the one
place it had never been applied. Nothing a user wrote before the rename
disappears: skills, themes, agents and commands keep loading from `.opencode`,
learnings are read from both directories and sorted together by date, and a plan
already written to the old path still resolves there rather than reading as
empty at a new one.

The global config directory needed nothing — Global.Path is XDG-based and has
been `unifia` since the rebrand.

One deliberate consequence: a tool sitting in a legacy `.opencode` directory
that declares npm dependencies no longer gets them installed, because that
directory is no longer written to. It still loads; only dependency installation
moves. Moving the directory to `.unifia` restores it.

Verified: typecheck clean, 682 tests across config, tool, session and
coexistence. Three new tests in test/config/paths.test.ts pin the discovery
order, the legacy-only case, and the read-only contract; the pre-existing
registry test that loads a tool from `.opencode/tool` now doubles as the
legacy-read regression. The single failure in the combined run
(processor-observability, 5082ms against a 5000ms limit) reproduces identically
on the base branch and passes in isolation — run contention, not this change.

* feat(storage): carte C8-A - rename opencode.db to unifia.db with one-shot copy

- TS: storage/db.ts exports DATABASE_FILE and LEGACY_DATABASE_FILE plus
  migrateLegacyDatabaseFile() that copies the legacy opencode.db (and its
  -wal / -shm siblings) to unifia.db on first access. Idempotent, never
  moves the source. getChannelPath() now returns the unifia path.
- TS: mobile-entry.ts:196 uses the new file as the JsonMigration marker.
- Rust: lib.rs sidecar_db_path() mirrors the TS migration with the same
  copy-not-move semantics. New migrate_legacy_db() and append_suffix()
  helpers. tracing::info! on copy, tracing::error! + Err on failure.
- Tests: db-migration.test.ts covers 5 cases (copy, -wal/-shm, idempotent,
  no source, both present). Rust db_migration_tests covers the 4 main
  cases (compile OK, runtime blocked by a Windows DLL env issue).
- db.test.ts expectations aligned to the new file naming.

Refs: Runbook-Autonome-Independance-Unifia-2026-08-10 carte C8-A
Gate: 5/5 - content identical, legacy preserved, idempotent, cargo check,
      tests cover all three cases (legacy present / absent / both)

* feat(keyring): carte C8-B - namespace keychain opencode.* -> unifia.*

- New crate unifia-keyring-shim owns the brand-prefixed keyring calls
  (get / set / delete) so the migration logic is unit-testable on a
  workstation without WebView2 / DirectML installed. It exposes a
  KeyringBackend trait; RealKeyringBackend delegates to the keyring
  crate, MockBackend is an in-process HashMap for tests. The keyring
  crate's own mock module cannot be used here because it does not
  persist data between Entry::new calls.
- auth_storage.rs now uses the shim and only the Tauri-side index
  management stays in the lib. The four Tauri commands keep the same
  public signature.
- Migration semantics match the runbook: get tries new first, falls
  back to legacy and rewrites on hit (legacy kept as backup); set
  writes to new only; delete removes BOTH prefixes so a logout
  cannot leave a phantom credential under the legacy namespace.
- Tests in keyring-shim/tests/keychain_namespace.rs cover 7 cases:
  rewrite on legacy hit, prefer new when both present, set writes
  to new only, delete clears both, delete is a no-op when nothing
  exists, get returns None when both empty, second read uses the
  rewritten new prefix.

Refs: Runbook-Autonome-Independance-Unifia-2026-08-10 carte C8-B
Gate: 3/3 - legacy credential becomes visible under new prefix,
      logout clears both prefixes (verified by 7 tests), cargo
      check passes

* refactor(packages): carte C9 - rename packages/opencode to packages/unifia + opencode-cli to unifia-cli

Directory rename:
- git mv packages/opencode packages/unifia (1380 files affected)

Path references: 245+31 files updated from packages/opencode to
packages/unifia, excluding the docs/autonomy/ provenance files
(UPSTREAM-*, BASELINE.md, MIGRATION-PLAN.md, REPO-INVENTORY.md)
which the runbook C9 gate explicitly preserves as historical record.

Sidecar rename: opencode-cli -> unifia-cli in producer and consumer:
- .github/workflows/{android,publish,release}.yml — CI artifact names
- packages/desktop-electron/{electron-builder.config.ts, .gitignore,
  scripts/{prepare,utils}.ts, src/main/cli.ts} — Electron packaging
- crates/unifia-supervisor/src/tests.rs — left alone: the /opt/opencode/
  opencode-cli path there is a forged-lease fixture the supervisor
  must reject, not a real install path.
- packages/unifia/src/account/index.ts — left alone: clientId is the
  OAuth client identifier registered with the provider.

The build script packages/unifia/script/build.ts already derives
the binary basename from pkg.name (= unifia) without hardcoding
the sidecar suffix; the copy-sidecar step in packages/desktop/scripts/
adds the -cli suffix when staging the artifact.

Tauri config (sidecars/unifia-cli-{triple}) was already aligned
with the new name; no change required there.

Bonus: pre-existing shellcheck warnings on test-tools.sh (SC2086 on
the basename call, SC2164 on a bare cd) were caught by the pre-commit
hook on the touched file and are fixed here to unblock the commit.

Validation:
- cargo check (packages/desktop/src-tauri): passes
- tsc --noEmit (packages/unifia): 1 pre-existing error in
  config.ts:634 (TS2589) confirmed to be present on the base
  branch before this commit; no new errors introduced
- grep -rn 'packages/opencode' (excl. node_modules): 3 files,
  all in docs/autonomy/ provenance, as required by the gate

* fix(build): repair relative path references missed by C9 rename

The C9 mass-rename caught all 'packages/opencode' absolute references
but missed the 'relative' imports and shell cd invocations that point
into the renamed directory. These broke three build steps the C9
gate did not exercise:

- packages/sdk/js/script/openapi.ts:26 — SDK build fetches
  'bun run dev generate' from the renamed CLI package.
- packages/app/e2e/fixtures.ts:4-5 and packages/app/script/e2e-local.ts:
  167-176 — e2e harness imports 'log', 'installation', 'flag',
  'server', 'instance' from the renamed CLI.
- packages/desktop-electron/scripts/utils.ts:14,118 and
  packages/desktop/scripts/{copy-sidecar,predev,utils}.ts —
  sidecar staging reads the CLI manifest, cds into the CLI directory
  for the build, and locates 'dist/' under the renamed path.
- packages/console/app/package.json:10 — Vite build runs the CLI
  schema generator at the renamed path.
- packages/mobile/scripts/prepare-android-runtime.sh:145 — Android
  bundle locates the CLI at 'mobile/../<cli>' for the runtime.

Caught by the 'unit (linux)' and 'unit (windows)' CI failures on
PR #25 — the SDK build is a transitive dep of 'bun turbo test:ci'.
The compliance-close bot had not yet acted; this is a real bug,
not a bot-driven noise. Pushing the fix re-triggers CI.

Refs: PR #25, Runbook-Autonome-Independance-Unifia-2026-08-10 C9

* fix(test): align plugin install tests with config-dir-migration write target

c1d76cf (config-dir-migration, in the lineage of feat/unifia-c8-c9)
changed packages/unifia/src/config/paths.ts to write to .unifia/ and
read from .opencode/ for backward compatibility. The
plugin.install.task tests in test/plugin/install.test.ts still read
from .opencode/, so after that commit the tests could not find the
config the install wrote — 17 ENOENT failures, caught by the
'unit (linux)' check on PR #25.

Bug was pre-existing in c1d76cf but invisible there because the
test branch (feat/unifia-rebrand-complete) does not include the
config-dir-migration commit. Surfaced only now that
feat/unifia-c8-c9 stacks on top of feat/unifia-config-dir-migration.

Aligned all 30 .opencode -> .unifia references in the test to the
new write target.

* fix(test): align remaining plugin install tests with config-dir-migration

Same root cause as the previous install.test.ts fix: c1d76cf
wrote plugin config to .unifia/ but the install-concurrency and
auth-override tests still read from .opencode/. Three concurrent
test cases were failing with ENOENT on Linux (and Windows, same
file).

The 30+ other .opencode references in test/ are intentional:
install-coexistence / uninstall-coexistence verify coexistence
with a real OpenCode install, config.test.ts exercises the legacy
.read path, agent.test.ts tests the .opencode/plans permission
glob, plugin-loader.test.ts verifies theme resolution. All
correctly preserved as-is per the c1d76cf 'legacy read' contract.

* refactor(mobile): finish Unifia runtime rename

* fix(workspace): clear rebrand follow-up debt

* fix(android): enforce release signing identity

* fix(e2e): follow renamed Unifia server package

* fix(e2e): start isolated Unifia backends

---------

Co-authored-by: MM2-B02-WORKER <mm2-b02@team-v3.local>
Rwanbt pushed a commit that referenced this pull request Aug 11, 2026
It was selected because it appeared on PRs #23, #24 and #25 — which turned
out not to prove what it looked like. `observability-sdk-drift.yml` filters
its `pull_request` trigger by `paths`, so it never started on #28, and a
required check that does not start stays "Expected — waiting for status"
forever. Requiring it would have permanently blocked every pull request that
does not touch the SDK.

Seven checks remain, each verified in its own workflow file to have an
unfiltered `pull_request` (or `pull_request_target`) trigger rather than
inferred from a sample of pull requests. The ledger records the trap so the
eighth is not added back on the same reasoning.
Rwanbt pushed a commit that referenced this pull request Aug 11, 2026
The previous entry said every alert path sat under `packages/opencode/` and
concluded the scan had to be replayed before review. That reasoning was wrong
in both halves, and it understated how much of the gate is already done.

The alerts are reported against `refs/heads/dev`, and `dev` has not received
the C9 rename, so those paths are correct for the branch they describe rather
than left over from an older tree. The scan is also current: CodeQL completed
successfully on 6cca33b, which is the head of PR #23.

More usefully, both `critical` findings are already fixed on
feat/unifia-rebrand-complete and will close when it merges — cargo-proxy.mjs
now carries the ALLOWED_TOOLS allowlist its own comment claimed but never
implemented, and github-run.ts checks the prefix after URL normalisation
instead of before. What is left for a human is two high and two medium
findings. Nothing was reviewed, modified or dismissed here.
* feat(npm): make the @UNiFia scope publishable

`@unifia/plugin` is injected into the package.json of every config directory
the CLI manages, pinned to the running CLI version, so the scope has to resolve
on npm. Three things stopped any of it from being installable:

- `@unifia/sdk-shared` was `private: true`, had no build and no publish script,
  while both `@unifia/sdk` and `@unifia/plugin` depend on it.
- All three declared `files: ["dist"]` against an `exports` map pointing at
  `./src/*.ts`. The publish scripts rewrote the map in place, but left it
  rewritten on failure and emitted `import` ahead of `types`.
- `tsc` is incremental here (composite). With a stale tsconfig.tsbuildinfo it
  reports success while emitting nothing, so `rm -rf dist && tsc` could produce
  an empty tarball. The tsbuildinfo now goes with dist, via a `compile` script.

The export rewrite moves to `@unifia/script/npm`, shared by the three publish
scripts, and restores the manifest byte-for-byte in a `finally`. Adds
`script/publish-npm.ts` to publish the libraries alone, in dependency order,
with UNIFIA_NPM_DRY_RUN for a pack-only run.

Each package gains a README, a LICENSE and the repository/homepage/bugs
metadata an npm page needs. `@types/node` is declared an optional peer of the
plugin: its `$` declarations name `Buffer` and `BufferEncoding`.

Verified against a local Verdaccio: publish in order, then a clean project
outside the workspace resolves `@unifia/plugin@1.3.15` through npm's own
installer, typechecks strict with skipLibCheck off, and imports at runtime.

* feat(release): cut the CLI's distribution loose from upstream

The fork already had its own release pipeline (fork-release.yml, gated on
Rwanbt/unifia) while upstream's publish.yml is gated on anomalyco/opencode and
cannot fire here. What was missing was npm, and what was left behind was a set
of paths still resolving to upstream.

Distribution now: GitHub Releases + the install script, the desktop updaters,
the Android APK, npm, and a container on ghcr.io/<owner>/unifia. Homebrew,
Chocolatey, Scoop, the AUR and the Zed extension are removed rather than
re-pointed — publishing to none of them, detecting them only produced upgrade
offers computed from someone else's version line.

Removed a release-triggered path that reached a third party: no repository gate
on sync-zed-extension.yml, so publishing a release here opened a pull request
on zed-industries/extensions carrying a manifest whose download URLs pointed at
anomalyco/opencode.

Upgrade path, all in installation/index.ts:
- the npm check read upstream's `opencode-ai` and then installed `unifia-ai` at
  whatever version that returned. Upstream is at 1.18.16 against this fork's
  1.3.15, so every npm install was told to upgrade to a release that does not
  exist.
- `unifia upgrade` on a curl install fetched and ran https://opencode.ai/install
  — upstream's installer, on a domain this fork does not control — replacing
  Unifia with the upstream product. It now runs the installer this repo ships as
  a release asset.
- curl installs were detected by `.opencode/bin` only, while `install` deploys
  to `.unifia/bin`, so a current install fell through to "unknown" and had no
  upgrade method at all.

packages/opencode/script/publish-npm.ts publishes the 12 platform packages and
the unifia-ai stub, reusing the binaries build-cli already cross-compiles on one
Linux runner. Both inherited publish scripts now refuse to run without
UNIFIA_ALLOW_UPSTREAM_PUBLISH, so a manual invocation cannot push to ghcr.io/
anomalyco, the AUR or upstream's Homebrew tap; they are otherwise untouched, so
the monthly upstream sync stays conflict-free.

Also: the Linux .deb registered its AppStream metadata as
ai.opencode.opencode.metainfo.xml while the file declares ai.unifia.desktop —
both an upstream identity and a spec violation, since the filename must match
the component id. Beta updater pointed at the pre-rename Rwanbt/opencode-beta.
Nix homepages and the installer's closing link pointed at opencode.ai.

Verified: typecheck clean, 7/7 installation tests (three of them new, asserting
no code path reaches upstream's registries), 185/185 CLI tests, both guards
fire, and publish-npm.ts dry-runs idempotently against a real build.

* fix(release): derive the version from unifia-ai, not upstream's opencode-ai

A release cut without UNIFIA_VERSION read upstream's latest npm version and
bumped that. Upstream is at 1.18.16 against this product's 1.3.x, so the next
tag would have jumped an entire version line that is not ours.

* refactor(release): name the product, not what it descends from

Unifia released as `fork-release.yml` on `v*-fork*` tags with
UNIFIA_CHANNEL=fork. That is the naming a fork gives itself; nothing downstream
required it — Installation.isPreview(), the only reader of the channel, has no
callers. The workflow is now `release.yml` on `v*` (still matching the
`v*-fork*` tags cut before this) with the channel on `latest`, which is also
what `npm i -g unifia-ai` resolves.

Deleted the stub it collides with: a second `release.yml`, added with the
Docker scaffolding and never wired to a build, fired on `v*.*.*` with no
repository gate and published an empty draft from `dist/*` and `*.tar.gz` at
the repo root — paths nothing produces.

`opencode.yml` becomes `unifia.yml` and runs the composite action this repo
ships in github/ rather than `anomalyco/opencode/github@<sha>`: every `/unifia`
comment was executing upstream's agent at a revision this fork does not
control. Trigger words follow (`/uni`, `/unifia`). The `opencode/claude-opus-4-5`
model stays — `opencode` there is a provider id (provider/schema.ts:27), not
branding.

Cleared the `@unifia-ai` spelling left by an intermediate rebrand pass. One of
them was load-bearing: file-viewer.spec.ts asserted the file viewer shows
`"@unifia-ai/app"` while the package is `@unifia/app`, so the assertion could
never have matched.

Verified: typecheck clean, 148/148 config and installation tests.

* feat(config): read `.opencode`, write `.unifia`

The project config directory — skills, themes, agents, commands, plugins,
learnings, plans — was `.opencode` on both the read and the write side. That is
the same directory the separately-installed OpenCode uses, so this product was
loading the other one's project configuration and, through installDependencies
on every config load, writing a package.json, a .gitignore and a node_modules
tree into it.

Reads now cover both names, writes only `.unifia`. The merge order is the rule
projectFiles() already applies to filenames — legacy first, current brand last
so it wins where both exist — extended to the directory name, which is the one
place it had never been applied. Nothing a user wrote before the rename
disappears: skills, themes, agents and commands keep loading from `.opencode`,
learnings are read from both directories and sorted together by date, and a plan
already written to the old path still resolves there rather than reading as
empty at a new one.

The global config directory needed nothing — Global.Path is XDG-based and has
been `unifia` since the rebrand.

One deliberate consequence: a tool sitting in a legacy `.opencode` directory
that declares npm dependencies no longer gets them installed, because that
directory is no longer written to. It still loads; only dependency installation
moves. Moving the directory to `.unifia` restores it.

Verified: typecheck clean, 682 tests across config, tool, session and
coexistence. Three new tests in test/config/paths.test.ts pin the discovery
order, the legacy-only case, and the read-only contract; the pre-existing
registry test that loads a tool from `.opencode/tool` now doubles as the
legacy-read regression. The single failure in the combined run
(processor-observability, 5082ms against a 5000ms limit) reproduces identically
on the base branch and passes in isolation — run contention, not this change.

* feat(storage): carte C8-A - rename opencode.db to unifia.db with one-shot copy

- TS: storage/db.ts exports DATABASE_FILE and LEGACY_DATABASE_FILE plus
  migrateLegacyDatabaseFile() that copies the legacy opencode.db (and its
  -wal / -shm siblings) to unifia.db on first access. Idempotent, never
  moves the source. getChannelPath() now returns the unifia path.
- TS: mobile-entry.ts:196 uses the new file as the JsonMigration marker.
- Rust: lib.rs sidecar_db_path() mirrors the TS migration with the same
  copy-not-move semantics. New migrate_legacy_db() and append_suffix()
  helpers. tracing::info! on copy, tracing::error! + Err on failure.
- Tests: db-migration.test.ts covers 5 cases (copy, -wal/-shm, idempotent,
  no source, both present). Rust db_migration_tests covers the 4 main
  cases (compile OK, runtime blocked by a Windows DLL env issue).
- db.test.ts expectations aligned to the new file naming.

Refs: Runbook-Autonome-Independance-Unifia-2026-08-10 carte C8-A
Gate: 5/5 - content identical, legacy preserved, idempotent, cargo check,
      tests cover all three cases (legacy present / absent / both)

* feat(keyring): carte C8-B - namespace keychain opencode.* -> unifia.*

- New crate unifia-keyring-shim owns the brand-prefixed keyring calls
  (get / set / delete) so the migration logic is unit-testable on a
  workstation without WebView2 / DirectML installed. It exposes a
  KeyringBackend trait; RealKeyringBackend delegates to the keyring
  crate, MockBackend is an in-process HashMap for tests. The keyring
  crate's own mock module cannot be used here because it does not
  persist data between Entry::new calls.
- auth_storage.rs now uses the shim and only the Tauri-side index
  management stays in the lib. The four Tauri commands keep the same
  public signature.
- Migration semantics match the runbook: get tries new first, falls
  back to legacy and rewrites on hit (legacy kept as backup); set
  writes to new only; delete removes BOTH prefixes so a logout
  cannot leave a phantom credential under the legacy namespace.
- Tests in keyring-shim/tests/keychain_namespace.rs cover 7 cases:
  rewrite on legacy hit, prefer new when both present, set writes
  to new only, delete clears both, delete is a no-op when nothing
  exists, get returns None when both empty, second read uses the
  rewritten new prefix.

Refs: Runbook-Autonome-Independance-Unifia-2026-08-10 carte C8-B
Gate: 3/3 - legacy credential becomes visible under new prefix,
      logout clears both prefixes (verified by 7 tests), cargo
      check passes

* refactor(packages): carte C9 - rename packages/opencode to packages/unifia + opencode-cli to unifia-cli

Directory rename:
- git mv packages/opencode packages/unifia (1380 files affected)

Path references: 245+31 files updated from packages/opencode to
packages/unifia, excluding the docs/autonomy/ provenance files
(UPSTREAM-*, BASELINE.md, MIGRATION-PLAN.md, REPO-INVENTORY.md)
which the runbook C9 gate explicitly preserves as historical record.

Sidecar rename: opencode-cli -> unifia-cli in producer and consumer:
- .github/workflows/{android,publish,release}.yml — CI artifact names
- packages/desktop-electron/{electron-builder.config.ts, .gitignore,
  scripts/{prepare,utils}.ts, src/main/cli.ts} — Electron packaging
- crates/unifia-supervisor/src/tests.rs — left alone: the /opt/opencode/
  opencode-cli path there is a forged-lease fixture the supervisor
  must reject, not a real install path.
- packages/unifia/src/account/index.ts — left alone: clientId is the
  OAuth client identifier registered with the provider.

The build script packages/unifia/script/build.ts already derives
the binary basename from pkg.name (= unifia) without hardcoding
the sidecar suffix; the copy-sidecar step in packages/desktop/scripts/
adds the -cli suffix when staging the artifact.

Tauri config (sidecars/unifia-cli-{triple}) was already aligned
with the new name; no change required there.

Bonus: pre-existing shellcheck warnings on test-tools.sh (SC2086 on
the basename call, SC2164 on a bare cd) were caught by the pre-commit
hook on the touched file and are fixed here to unblock the commit.

Validation:
- cargo check (packages/desktop/src-tauri): passes
- tsc --noEmit (packages/unifia): 1 pre-existing error in
  config.ts:634 (TS2589) confirmed to be present on the base
  branch before this commit; no new errors introduced
- grep -rn 'packages/opencode' (excl. node_modules): 3 files,
  all in docs/autonomy/ provenance, as required by the gate

* fix(build): repair relative path references missed by C9 rename

The C9 mass-rename caught all 'packages/opencode' absolute references
but missed the 'relative' imports and shell cd invocations that point
into the renamed directory. These broke three build steps the C9
gate did not exercise:

- packages/sdk/js/script/openapi.ts:26 — SDK build fetches
  'bun run dev generate' from the renamed CLI package.
- packages/app/e2e/fixtures.ts:4-5 and packages/app/script/e2e-local.ts:
  167-176 — e2e harness imports 'log', 'installation', 'flag',
  'server', 'instance' from the renamed CLI.
- packages/desktop-electron/scripts/utils.ts:14,118 and
  packages/desktop/scripts/{copy-sidecar,predev,utils}.ts —
  sidecar staging reads the CLI manifest, cds into the CLI directory
  for the build, and locates 'dist/' under the renamed path.
- packages/console/app/package.json:10 — Vite build runs the CLI
  schema generator at the renamed path.
- packages/mobile/scripts/prepare-android-runtime.sh:145 — Android
  bundle locates the CLI at 'mobile/../<cli>' for the runtime.

Caught by the 'unit (linux)' and 'unit (windows)' CI failures on
PR #25 — the SDK build is a transitive dep of 'bun turbo test:ci'.
The compliance-close bot had not yet acted; this is a real bug,
not a bot-driven noise. Pushing the fix re-triggers CI.

Refs: PR #25, Runbook-Autonome-Independance-Unifia-2026-08-10 C9

* fix(test): align plugin install tests with config-dir-migration write target

c1d76cf (config-dir-migration, in the lineage of feat/unifia-c8-c9)
changed packages/unifia/src/config/paths.ts to write to .unifia/ and
read from .opencode/ for backward compatibility. The
plugin.install.task tests in test/plugin/install.test.ts still read
from .opencode/, so after that commit the tests could not find the
config the install wrote — 17 ENOENT failures, caught by the
'unit (linux)' check on PR #25.

Bug was pre-existing in c1d76cf but invisible there because the
test branch (feat/unifia-rebrand-complete) does not include the
config-dir-migration commit. Surfaced only now that
feat/unifia-c8-c9 stacks on top of feat/unifia-config-dir-migration.

Aligned all 30 .opencode -> .unifia references in the test to the
new write target.

* fix(test): align remaining plugin install tests with config-dir-migration

Same root cause as the previous install.test.ts fix: c1d76cf
wrote plugin config to .unifia/ but the install-concurrency and
auth-override tests still read from .opencode/. Three concurrent
test cases were failing with ENOENT on Linux (and Windows, same
file).

The 30+ other .opencode references in test/ are intentional:
install-coexistence / uninstall-coexistence verify coexistence
with a real OpenCode install, config.test.ts exercises the legacy
.read path, agent.test.ts tests the .opencode/plans permission
glob, plugin-loader.test.ts verifies theme resolution. All
correctly preserved as-is per the c1d76cf 'legacy read' contract.

* refactor(mobile): finish Unifia runtime rename

* fix(workspace): clear rebrand follow-up debt

* fix(android): enforce release signing identity

* fix(e2e): follow renamed Unifia server package

* fix(e2e): start isolated Unifia backends

* fix(identity): read the isolated UNIFIA_* environment names

Every shell that launches the sidecar — the mobile Rust runtime
(runtime/server.rs), the Tauri desktop (cli.rs) and Electron (main/cli.ts) —
exports UNIFIA_CLIENT, UNIFIA_AUTH_STORAGE and UNIFIA_SERVER_*. These are the
`isolated` class in config/identity.json, so Flag deliberately refuses to
satisfy them from the OPENCODE_ spelling: an environment prepared for the
separately-installed OpenCode must not reach this product's keychain or
credentials.

Six consumers still read the legacy names straight from process.env, so the
value the shells emit never arrived and each silently took its default:

- github/auth.ts picked plaintext `file` storage on mobile instead of
  `encrypted-file`
- github/credentials.ts skipped the mobile git-credential passthrough
- local-llm-server/index.ts took the desktop spawn path on Android, where
  LlamaService owns llama-server
- tool/bash.ts dropped the --init-file workaround the musl bash build needs
- mobile-entry.ts *set* OPENCODE_CLIENT, which isolatedValue ignores, leaving
  Flag.UNIFIA_CLIENT reading "cli" inside the mobile sidecar
- cli/cmd/team.ts authenticated with OPENCODE_SERVER_* and defaulted the
  username to "opencode", while server/auth-jwt.ts validates UNIFIA_SERVER_*
  against "unifia" — the Team CLI could never authenticate

Also names the Team CLI surface Unifia in its help text.

* fix(config): write project config to the current brand directory

ConfigPaths.PROJECT_DIRECTORY is `.unifia` and its doc comment states that
`.opencode` is read-only by contract: the same directory belongs to the
separately-installed OpenCode, so writing there drops files into that
product's project. plugin/install.ts already respects this; three write
paths did not.

- cli/cmd/init.ts created `.opencode/unifia.jsonc` while telling the user it
  had written `.opencode/opencode.jsonc` — neither path was the real one
- cli/cmd/agent.ts created project agents under `.opencode/agent`
- local-models/index.ts wrote `.opencode/opencode.jsonc`, legacy in both the
  directory and the file name

All three now resolve through ConfigPaths.PROJECT_DIRECTORY, and init derives
its user-facing label from the same constant so the message cannot drift from
the path again.

* docs(brand): name the product Unifia in system prompts and templates

The ten system prompts introduced the agent as OpenCode and pointed users at
anomalyco/opencode and opencode.ai for feedback and documentation, so a Unifia
session told the user it was a different product and sent bug reports
upstream. Feedback now goes to github.com/Rwanbt/unifia/issues and the docs
reference is the repository, which is the only location this fork controls —
no domain is invented.

initialize.txt likewise asked the model to write AGENTS.md for "future
OpenCode sessions" and to reference `opencode.json`, which is the legacy
config name rather than the one `unifia init` now writes.

* chore(governance): make the fork own its own docs and review rules

Six inherited or mechanically-rewritten statements described a repository
this is not.

Upstream is named OpenCode, not Unifia. A global rename had rewritten the
*upstream* references too, so README and AGENTS.md called it "upstream Unifia"
and described this repo as a fork of itself. Restored in six places; the fork
notice now names the two products distinctly, which is the whole point of it.

- SECURITY.md sent vulnerability reports to anomalyco/opencode advisories.
  Reports about fork-only code (desktop/mobile shells, local model
  orchestration, Team, identity gates) reached maintainers who cannot act on
  them. Now routes to this repository, with a table for deciding which project
  owns a defect, and an escalation path sized for a single maintainer instead
  of upstream's 6-business-day organisational SLA.
- .github/CODEOWNERS assigned this fork's packages to @adamdotdevin and
  @Brendonovich, who have no access here, and routed `packages/tauri/`, which
  does not exist. Rewritten around the only account with access, so
  "require Code Owners review" becomes enforceable rather than unsatisfiable.
- README's build badge pointed at fork-release.yml, deleted when the workflow
  was renamed to release.yml, so it rendered as a permanent "no status".
- README still said "The final product name is not fixed yet" and called this
  a "working-name fork" — contradicted by config/identity.json, which the
  identity gate enforces on every surface.
- AGENTS.md claimed the default branch is `dev` and that a local `main` may not
  exist. `gh api repos/Rwanbt/unifia --jq .default_branch` returns `main`, and
  the two branches have diverged (27 / 32 commits).
- CHANGELOG linked Rwanbt/opencode, the repository's pre-rename name. It only
  resolves through a GitHub redirect; the canonical name is now used and the
  former one is stated once as history.

Also drops two placeholder root scripts (`random`, `hello`) with no callers.

* docs(readiness): make one gate ledger authoritative

Three documents claimed to describe release readiness and disagreed.
PROD_READINESS.md already called itself the single entry point but was last
assessed on 2026-04-19, so it predated the whole rebrand and named none of
its gates; PRODUCTION_READINESS.md was a never-filled checklist dated
2026-07-31 whose empty boxes could be read as "not applicable" rather than
"never verified".

PROD_READINESS.md keeps the role and gains the ledger the decision actually
needs, with each gate separated by nature — local, CI, human security, GitHub
governance, signing and device, packaging, product identity, domain, external
publication — and a stated rule for what may be promoted. Every entry cites
the run, the count or the API response it rests on; nothing is checked from a
summary. The 2026-04-19 UX verdict is kept and stated as open in addition to,
not instead of, the rebrand gates.

Also records the classification of the remaining `opencode` references, so a
future pass can tell a legitimate external contract from an unfinished rename
without re-deriving it, and the reason the branch-protection JSON is prepared
but not applied: a single-maintainer repository cannot satisfy a required
self-approval, so requiring one alongside enforce_admins would lock both
branches permanently.

PRODUCTION_READINESS.md is marked superseded rather than deleted — it remains
a usable thematic checklist, it just does not decide anything.

* docs(readiness): record the branch protection actually applied

`main` and `dev` now carry 8 required status checks, one approval,
dismiss-stale-reviews, linear history, and the force-push/deletion bans they
already had. `enforce_admins` stays false on purpose and the ledger now says
why: GitHub forbids approving your own pull request, so requiring an approval
while also binding admins would leave a single-maintainer repository with no
one able to merge anything. The same reasoning keeps require_code_owner_reviews
off even though CODEOWNERS is now correct.

The previous configuration is saved outside the repository, so the change is
reversible.

* fix(governance): drop sdk-drift from the required checks

It was selected because it appeared on PRs #23, #24 and #25 — which turned
out not to prove what it looked like. `observability-sdk-drift.yml` filters
its `pull_request` trigger by `paths`, so it never started on #28, and a
required check that does not start stays "Expected — waiting for status"
forever. Requiring it would have permanently blocked every pull request that
does not touch the SDK.

Seven checks remain, each verified in its own workflow file to have an
unfiltered `pull_request` (or `pull_request_target`) trigger rather than
inferred from a sample of pull requests. The ledger records the trap so the
eighth is not added back on the same reasoning.

* fix(brand): point fork-owned links at the repository, not a domain

Applies the domain decision of 2026-08-11: the fork controls no domain, so
every link it owns points at github.com/Rwanbt/unifia. No domain is invented;
`unifia.ai` appears only in the comment forbidding it.

Deliberately narrow. `opencode.ai` occurs 3749 times in this repository, and
roughly 3300 of those are external contracts a rename would break: 2291
`$schema` URLs that make editors validate config files, ~700 Zen API endpoints
the `opencode` provider actually calls, and ~200 theme schemas. Also left
pointing upstream are the docs site's `console`, `email` and `discord` — Zen's
console, anoma.ly's enterprise contact and upstream's Discord are their
services, described on pages documenting them, so redirecting those would
replace a working instruction with a wrong one.

What changed instead:

- packages/web/config.mjs is the single producer every one of the 21 locales
  reads for `config.github`, so `edit this page` and the SDK type links stopped
  sending contributors to anomalyco/opencode. `url` becomes overridable through
  UNIFIA_SITE_URL and otherwise names the repository; while the site is
  undeployed there is no origin to name, and the file now says so.
- astro.config.mjs called `../opencode/script/schema.ts`, a path the C9 rename
  missed. spawnSync reports a missing program through `error` rather than
  throwing and nothing read it, so the docs build kept succeeding while
  emitting no config schema at all. Path corrected and both failure modes are
  now raised.
- The CLI's own help — the TUI docs action, the comment `unifia init` writes
  into every generated config, and two config-schema descriptions users see as
  editor tooltips — pointed at upstream documentation.

config-schema.ts feeds packages/sdk, so `./script/generate.ts` was re-run and
the regenerated openapi.json and v2 types are included; committing the schema
change alone would have failed the `sdk in sync with server` gate.

Verified: packages/unifia and packages/sdk/js typecheck clean; 329 tests pass
across test/config and test/cli; biome clean on 1247 files.

* docs(readiness): correct the CodeQL gate — the alerts were not stale

The previous entry said every alert path sat under `packages/opencode/` and
concluded the scan had to be replayed before review. That reasoning was wrong
in both halves, and it understated how much of the gate is already done.

The alerts are reported against `refs/heads/dev`, and `dev` has not received
the C9 rename, so those paths are correct for the branch they describe rather
than left over from an older tree. The scan is also current: CodeQL completed
successfully on 6cca33b, which is the head of PR #23.

More usefully, both `critical` findings are already fixed on
feat/unifia-rebrand-complete and will close when it merges — cargo-proxy.mjs
now carries the ALLOWED_TOOLS allowlist its own comment claimed but never
implemented, and github-run.ts checks the prefix after URL normalisation
instead of before. What is left for a human is two high and two medium
findings. Nothing was reviewed, modified or dismissed here.

* fix(lsp): add UNIFIA_DISABLE_LSP and use it in the e2e runner

`UNIFIA_DISABLE_LSP_DOWNLOAD`, which the e2e runner already set, only stops
*fetching* a language server that is missing. The servers the runner image
already carries still start, and rust, typescript and julials each spend the
full 45 s `initialize` timeout before giving up, concurrently with Playwright's
own timers. The suite exercises no LSP feature, so it was paying that for
nothing.

Config `lsp: false` already expressed exactly this, so the new flag routes to
the same branch rather than adding a second mechanism; it exists for callers
with no config file to edit, which is the runner's situation. Fork-owned, so
no OPENCODE_ spelling is accepted. A test pins that, alongside the one that
matters most: DISABLE_LSP and DISABLE_LSP_DOWNLOAD stay independent in both
directions, since conflating them is what hid this.

What this measurably does: the runner logs `reason=UNIFIA_DISABLE_LSP all LSPs
are disabled`, with zero `spawned lsp server` and zero 45 s timeouts against
four before, and the run is shorter.

What it does NOT do, stated because the first draft of this claimed otherwise:
it does not stabilise the suite. Three local runs of the same two tests gave
LSP on -> pass (43.7 s), LSP off -> fail, LSP off -> pass (37.3 s). The tests
flake independently of the language servers, so the LSP timeouts were a real
cost but not the cause. The remaining cause is not identified, and this commit
should not be read as closing the e2e gate.

Verified: packages/unifia typecheck clean, 10 flag tests pass, packages/app
typecheck clean.

---------

Co-authored-by: MM2-B02-WORKER <mm2-b02@team-v3.local>
* fix(app,mobile): trim trailing slashes in linear time, from one owner

CodeQL js/polynomial-redos (anomalyco#278) reports `replace(/\/+$/, "")` in
remote-connect.tsx. The anchored `+` makes the engine retry from every
offset when the slash run never reaches `$`, so the cost is quadratic in
a length the user chooses by pasting.

Measured on Bun 1.3.14 with `"https://h/" + "/".repeat(n) + "x"`:

  n         regex       backward walk
  10 000    222 ms      0.004 ms
  50 000    2 914 ms    0.004 ms
  100 000   8 001 ms    0.004 ms

8 s on the main thread is a frozen WebView, so this is a real defect and
not only a rule firing.

The alert named one site; the pattern is the class. Every place that
normalizes a URL the user typed carries it:

  - app  context/server.tsx    normalizeServerUrl, serverName
  - app  connect/remote-connect.tsx
  - mobile entry.tsx           a verbatim copy of normalizeServerUrl
  - mobile notifications.ts    NotificationBridge's constructor

`trimTrailingSlashes` now lives in packages/app/src/utils/url.ts and is
exported from @unifia/app, and mobile's inline duplicate of
normalizeServerUrl calls the shared function instead of repeating its two
lines — the duplicate is how the fix would have had to be found twice.

The remaining `replace(/\/+$/, "")` occurrences normalize filesystem
paths and config keys (file/index.ts, worker-runtime.ts, autocomplete,
dialog-select-directory). They are not reachable from a pasted string of
attacker-chosen length and are left alone.

Test: packages/app/src/utils/url.test.ts asserts equivalence with the
regex it replaces and bounds the 100 000-slash case to 100 ms.

* fix(team,github): make both flagged file writes exclusive-create

Two CodeQL findings, one root shape: a write that assumes it is creating
the file it is about to harden, without saying so to the kernel.

js/file-system-race (anomalyco#334) — team/worktree-manager.ts
`existsSync(marker)` then `writeFileSync(marker)` is a TOCTOU, and this
module exists precisely so several workers can act on worktrees at once:
two of them both observe "absent" and both write, and the second silently
overwrites a marker the first is already being read for. Replaced by the
`wx` flag (O_CREAT|O_EXCL) so the kernel picks the winner in one call.
EEXIST is the idempotent path and the only error swallowed.

js/http-to-file-access (anomalyco#333) — github/auth.ts
The taint the rule follows — a GitHub HTTP response reaching a file write
— is not exploitable: the destination is the module constant on line 22,
never the network data, and the content round-trips through JSON.parse.
Reported as such rather than closed; that call is Erwan's.

But the flagged line held a real defect. `mode` on writeFile only applies
when writeFile creates the file, and the temp path was the fixed
`github-auth.json.tmp`. Left behind by a crash — or planted by anything
that can write the data dir — it was reused as-is and the OAuth access
token was persisted with whatever permissions it already had. The write
now uses a unique name and `wx`, so `mode: 0o600` is always the mode of
the file that receives the token.

Tests
- worktree-manager.test.ts: creation, no-clobber, and a POSIX-only case
  proving `wx` refuses to write through a planted symlink. That last one
  is skipped on Windows on measurement, not on principle — CreateFile
  with CREATE_NEW follows the reparse point and creates the target there,
  so the flag buys nothing and the assertion would fail for an unrelated
  reason.
- github/auth.test.ts: a stale 0644 `.tmp` is left untouched and the
  persisted session is 0600. Verified decisive — reverting only auth.ts
  fails it with ENOENT on the stale file (the old code truncated and
  renamed it away).

* fix(e2e): stop disabling the LSP by default, and drop the claim that justified it

The e2e runner defaulted UNIFIA_DISABLE_LSP to "true" on the stated
grounds that language servers were "the mechanism behind the suite's
flakiness". That claim was never measured, and measurement refutes it:
three runs of the same two tests give LSP on -> 2 passed (43.7 s), LSP
off -> 2 failed, LSP off -> 2 passed (37.3 s). The suite flakes either
way.

What the flag does buy is real — four 45 s `initialize` timeouts and
every `spawned lsp server` line disappear — but that is a cost removed,
not a cause. Left on by default it would have hidden any LSP regression
from the suite permanently, in exchange for a shorter run.

The flag itself stays: it is a legitimate opt-in, it has four tests in
test/flag/disable-lsp.test.ts, and its gate sits at the same point as
`cfg.lsp === false` (lsp/index.ts:303). It is simply no longer defaulted
— `serverEnv` already spreads process.env, so setting it in the
environment still works.

The same false claim was duplicated in the flag's own doc comment; it is
corrected there too, with an explicit "do not reach for this to stabilise
a test".

Nothing in .github/workflows sets UNIFIA_DISABLE_LSP, so CI picks the
language servers back up from this change alone.

* fix(e2e): submit only once the composer has an agent and a model

Root cause of the prompt-family failures, read off the Playwright call log of
run 31480610511 rather than inferred:

    - waiting for getByRole('button', { name: 'Send' }).first()
    - attempting click action
      - <div data-slot="toast-description">Choose an agent and model before
        sending a prompt.</div> ... intercepts pointer events
    208 x retrying click action

`submit.ts:306` refuses the prompt outright when either the agent or the model
is missing — it shows that toast and returns, so nothing is sent and no request
ever reaches the mock LLM. The helper in fixtures.ts typed and pressed Enter as
soon as the prompt input was *visible*, but agent and model are resolved from
the provider list the backend serves, which lands after the input renders.
Typing before then is the race. That is the exact trap e2e/AGENTS.md names:
"Do not treat a visible element as proof that the app will route the next
action to it."

Everything else in those failures is cascade. `started` never moves, so the
helper falls back to clicking Send; the click is intercepted; the test burns
its 120 s timeout; the queued mock response is never consumed and the `llm`
fixture teardown reports "TestLLMServer still has 1 queued response(s)". Four
of the twelve common failures were that teardown message — a symptom reported
as if it were the defect.

`waitPromptReady` polls the committed `__opencode_e2e.model.current` probe for
both `agent` and `model`, which is the same state submit.ts checks. Semantic
app state, no wall-clock wait, no new probe: the one it needs already existed
and was already used by the noReply branch two functions above.

Second defect, independent and also from the same call log: the debug bar
`<aside aria-label="Development performance diagnostics">` is `fixed bottom-3
right-3 z-50 pointer-events-auto` and 324 px wide, which parks it on top of the
composer's Send button. That is what the 208 retries were hitting once the
toast faded. It is dev-only telemetry, never shipped, and nothing in the suite
exercises it, so layout.tsx no longer renders it when the e2e harness is
present. Without this the fallback click can never land, and a fast, legible
failure becomes a timeout.

`e2eActive()` reads the `__opencode_e2e` marker the harness installs from an
init script. Nothing else writes it, so a normal page always reads false and
the bar is unchanged for developers.

* fix(e2e): seed the model the backend actually serves

This is the first causal defect behind the prompt-family failures, and it is
the repo's signature shape: a producer whose consumer was never wired to it.

`fixtures.ts` derived the model it seeds into the browser from
OPENCODE_E2E_MODEL, defaulting to `opencode/gpt-5-nano`. That variable is set
by `script/e2e-local.ts` for `seed-e2e.ts`, which writes a message record on
the *shared* backend. The browser does not talk to that backend — it talks to
the isolated per-worker backend started by `e2e/backend.ts`. Asked what it
serves:

    HTTP 200
    provider=local-llm models=1 gemma-4-E4B-it

One provider. No `opencode`, no `gpt-5-nano`: the Zen provider is dropped for
having zero models without credentials, which is the
"provider removed: zero models after filtering" warning printed on every test.

So the composer was seeded with a model that does not exist in the list it
reads, `local.model.current()` stayed undefined, and `submit.ts:306` refused
every prompt before sending it. Which model it is does not matter for routing —
with OPENCODE_E2E_LLM_URL set, `provider.ts:797` sends every model to the mock
regardless of provider. What matters is only that the id is in the list.

The seed is now resolved from `/config/providers` on the backend the browser
will use, once per worker. One authoritative source instead of a constant that
had drifted away from the thing it was meant to describe. OPENCODE_E2E_MODEL is
still honoured when the backend actually serves it, so pointing the suite at a
specific model keeps working; `seedStorage` now requires a model rather than
defaulting to one, so this cannot silently drift again.

Measured before this branch, on the base commit 8c3e08f (run 31495152621):
13 failures / 119, after 3 attempts each.

* fix(test): stop the sanitizer fuzz budget failing on scheduler noise

`unit (windows)` went red on run 31501895233 with one failure:
`sanitizer-fuzz.test.ts:51`, the per-iteration wall-clock assertion. The same
file passes in isolation on the same machine — twice, 45.4 s cold and 4.3 s
warm, 6 pass — and the base commit's own `unit (windows)` was green. Nothing in
this branch touches the sanitizer.

That is contention, and the test was built to catch it: 120 iterations each
asserted against a 200 ms ceiling gives 120 chances per run for a GC pause or a
preemption on a shared runner to fail a check that proves nothing about the
code under test.

The budget exists to catch catastrophic backtracking, which blows up by orders
of magnitude — seconds on a 4 KB input. So the threshold only has to sit
clearly above scheduler noise and clearly below a blow-up, and 200 ms sat in
the noise. The per-iteration ceiling moves to ReDoS scale (2 s) and a total
budget across the 120 iterations is added next to it. One stalled iteration now
trips neither; a single pathological input still trips the ceiling, and a
sanitizer that got slow across the board still trips the total. Strictly more
sensitive to the thing being tested, and no longer sensitive to the runner.

Propagation grep: the sibling budgets in `sanitizer.test.ts` (50 ms / 200 ms)
are single-shot, not loops, so they carry 1/120th of the exposure and have not
been observed failing. Left alone rather than churned without evidence, and
reported.

* fix(test): write the sanitizer fuzz control chars as escapes so the file diffs

A literal NUL in CONTROL_CHARS made git classify this file as binary. Every diff on it showed as `0 insertions, 0 deletions` and could not be reviewed — including the budget change in the previous commit. Same six code points, written as \u escapes: identical values, identical behaviour, reviewable diff.

* docs(readiness): record the measured e2e root cause and the CodeQL verdicts

G3: the LSP hypothesis is refuted and the real cause named — the browser was seeded with a model the isolated e2e backend does not serve, so submit.ts:306 refused every prompt. Common-failure count corrected from 11 to 12.

G4: the four remaining alerts are classified with cited evidence, two fixed at source, one already fixed on the branch, and js/http-to-file-access left open for a human decision. None closed by the agent.

G7: the build chain is proven — unsigned APK produced and apksigner confirms no key was used — so only the keystore blocks it.

G1: figures re-measured on this branch, including the packages/mobile trap where bun test and bun run test disagree by 31 failures.

* fix(test): harden E2E provider and test isolation

* fix(test): align E2E assertions with persisted UI state

* fix(lsp): drain responses before stopping servers

---------

Co-authored-by: MM2-B02-WORKER <mm2-b02@team-v3.local>
const crypto = await import("node:crypto")
const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
try {
await fs.writeFile(tmp, JSON.stringify(session, null, 2), { mode: 0o600, flag: "wx" })
Rwanbt pushed a commit that referenced this pull request Aug 11, 2026
G9 decision by the owner (2026-08-11): no domain is acquired, the project
publishes no website, and its canonical location is github.com/Rwanbt/unifia.

The starting point was worse than "packages/web quotes opencode.ai". The
rebrand had *fabricated* unifia.ai — a domain nobody has registered, and
therefore claimable by a third party — inside executable surfaces:

- infra/stage.ts used it as the production domain. It now sits behind
  UNIFIA_ALLOW_UPSTREAM_DEPLOY, mirroring script/publish.ts, and the
  production domain reverts to upstream's real one, reachable only under
  that opt-in.
- deploy.yml fired on `push: [dev, production]`. Merging PR #23 into `dev`
  would therefore have launched `sst deploy` against Cloudflare hostnames,
  a Stripe webhook and PlanetScale branches that are not ours. It is now
  workflow_dispatch with an explicit confirmation input.
- CODE_OF_CONDUCT.md and SECURITY-INCIDENT-RESPONSE.md routed abuse and
  vulnerability reports to conduct@/security@unifia.ai — addresses that
  cannot receive anything. They now use GitHub private reporting.
- Both desktop menus opened https://unifia.ai/docs.
- 17 app locales labelled the link "unifia.ai/zen" while the href pointed
  at opencode.ai/zen.
- entry.tsx branched on hostname "unifia.ai": dead code, since the fork
  serves from no domain.
- Synthetic git identities used @unifia.ai; they now use .invalid
  (RFC 2606), which cannot resolve by construction.

One pattern recurred: the rebrand renamed the *text* without the *link*.
Check both directions when renaming.

packages/web and packages/console stay out of scope by decision — they are
website surfaces the fork does not publish, and their deploy is neutralised
above. Remaining mentions elsewhere are descriptive (audit reports, plans,
explicit prohibitions) and direct nobody to the domain.

Evidence: typecheck 35/35; identity gate "7 surfaces agree"; brand gate
"30/30 masters, 208/208 generated"; packages/app 686 pass / 0 fail.
…34)

* fix(deps): patch direct security vulnerabilities

* fix(deps): bump second security batch and resync generated SDK

Second isolated supply-chain batch: hono, turbo, axios, postcss,
ip-address, undici, @remix-run/router, @hono/node-server and esbuild
overrides, plus sharp, ws, @babel/core and srvx. vitest moves to 3.2.7
in packages/contracts and @hey-api/openapi-ts to 0.99.0 in the SDK.

The generator bump changes emitted output, so packages/sdk is
regenerated here; committing the bump without it would fail the
"sdk in sync with server" gate. openapi.json itself is unchanged —
only the generator's reading of it moved.

That new reading surfaced two latent defects, both real:

- RequestResult now models `response` as optional, because a failure
  can predate any reply (request construction or network error). The
  workspace dialog dereferenced it unguarded, so a network error could
  throw instead of reporting. It now falls through to the existing
  error path rather than looping.
- The generator now propagates required-ness from the spec. The spec
  marks `extra` required on experimental.workspace.create, and the TUI
  call omitted it, violating the server contract. It now passes
  `extra: null`, matching the two existing call sites in
  team/opencode-application.ts.

Evidence: typecheck 35/35; conformance gate 8/8 including
packages/contracts 32/32 under vitest 3; packages/app 694 pass / 0 fail;
packages/unifia 4236 pass / 10 skip / 0 fail across 363 files.
bun audit: 47 alerts remaining (12 high), down from 101.

* fix(app): finish the contract fixes the SDK generator exposed

Four more instances of the same two defect classes already fixed in
packages/unifia. I had missed them because turbo and the `tsgo -b` build
outputs under node_modules/.ts-dist were caching a stale green result: the
monorepo typecheck reported 35/35 locally while CI, starting from a clean
checkout, failed @unifia/mobile#typecheck. Clearing .ts-dist reproduces it.

Optional `response` (a failure can precede any reply):

- context/sync.tsx and pages/layout/prefetch.ts both read
  `messages.response.headers` unguarded. Optional chaining preserves the
  existing semantics exactly, since both already coalesce to undefined and
  treat a missing cursor as "complete".

Required-ness propagated from the spec:

- openapi.json marks `method` required on provider.oauth.callback, and two of
  the three call sites in dialog-connect-provider.tsx passed
  `store.methodIndex`, which is `number | undefined`. The third call site at
  line 352 already guarded with `if (store.methodIndex === undefined) return`;
  the two others now do the same rather than inventing a different shape.

Evidence, with every build cache cleared first: typecheck 35/35; conformance
gate 8/8; packages/app 694 pass / 0 fail.

---------

Co-authored-by: MM2-B02-WORKER <mm2-b02@team-v3.local>
record = Object.create(null) as Record<string, unknown>
params[slot] = record
}
record[key] = value
Rwanbt and others added 2 commits August 11, 2026 22:11
G9 decision by the owner (2026-08-11): no domain is acquired, the project
publishes no website, and its canonical location is github.com/Rwanbt/unifia.

The starting point was worse than "packages/web quotes opencode.ai". The
rebrand had *fabricated* unifia.ai — a domain nobody has registered, and
therefore claimable by a third party — inside executable surfaces:

- infra/stage.ts used it as the production domain. It now sits behind
  UNIFIA_ALLOW_UPSTREAM_DEPLOY, mirroring script/publish.ts, and the
  production domain reverts to upstream's real one, reachable only under
  that opt-in.
- deploy.yml fired on `push: [dev, production]`. Merging PR #23 into `dev`
  would therefore have launched `sst deploy` against Cloudflare hostnames,
  a Stripe webhook and PlanetScale branches that are not ours. It is now
  workflow_dispatch with an explicit confirmation input.
- CODE_OF_CONDUCT.md and SECURITY-INCIDENT-RESPONSE.md routed abuse and
  vulnerability reports to conduct@/security@unifia.ai — addresses that
  cannot receive anything. They now use GitHub private reporting.
- Both desktop menus opened https://unifia.ai/docs.
- 17 app locales labelled the link "unifia.ai/zen" while the href pointed
  at opencode.ai/zen.
- entry.tsx branched on hostname "unifia.ai": dead code, since the fork
  serves from no domain.
- Synthetic git identities used @unifia.ai; they now use .invalid
  (RFC 2606), which cannot resolve by construction.

One pattern recurred: the rebrand renamed the *text* without the *link*.
Check both directions when renaming.

packages/web and packages/console stay out of scope by decision — they are
website surfaces the fork does not publish, and their deploy is neutralised
above. Remaining mentions elsewhere are descriptive (audit reports, plans,
explicit prohibitions) and direct nobody to the domain.

Evidence: typecheck 35/35; identity gate "7 surfaces agree"; brand gate
"30/30 masters, 208/208 generated"; packages/app 686 pass / 0 fail.

Co-authored-by: MM2-B02-WORKER <mm2-b02@team-v3.local>
…36)

The selector persisted every toggle, but the server schema requires at least two
distinct models and answers 400 below that. Since `selected()` only advanced
after a successful save, the first pick of an empty selection failed forever:
reaching two models required passing through one. On a fresh Android install the
Team selector was therefore permanently unusable, showing only "Failed to save
global Team configuration" on every tap.

Extract the toggle decision into a pure `planTeamToggle()` that stages
sub-minimal selections locally and only persists once MIN_TEAM_MODELS is reached.
`MIN_TEAM_MODELS` replaces the `2` that was hardcoded on both sides of the
network boundary; the server schema stays the source of truth. The decision is
pure because packages/app has no testing-library, so an inline guard in the JSX
would have shipped without any test.

Also in this commit:

- Surface the server error detail when saving a Team or Debate configuration
  fails. A validation 400 and a network failure previously raised the same
  opaque message, which is what made this bug unreadable on device.
- Relax `validateTeamSelection` to require two available models rather than all
  of them, and filter the persisted selection against connected providers on
  mount, mirroring DebateModelSelector. A single retired model no longer
  invalidates an otherwise usable selection.
- Replace the raw NUL byte in the FAVORITES_GROUP sentinel with the `\0` escape.
  Same string value, but git no longer classifies the file as binary, so the
  component is diffable and reviewable again.

Verified on a Mi 10 Pro (aarch64 release build): first pick no longer errors,
the second persists, and the selection survives a restart. 695 app tests pass,
including 8 new ones pinning that a sub-minimal selection is never sent.

Co-authored-by: MM2-B02-WORKER <mm2-b02@team-v3.local>
@Rwanbt
Rwanbt merged commit 91daa35 into dev Aug 11, 2026
23 of 24 checks passed
@Rwanbt
Rwanbt deleted the feat/unifia-rebrand-complete branch August 11, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants