Skip to content

Latest commit

 

History

History
431 lines (396 loc) · 326 KB

DIVERGENCES.md

File metadata and controls

431 lines (396 loc) · 326 KB

Divergences from upstream

Every deliberate change to an upstream file is listed in the Changed upstream files table. When an upstream sync patch (see Syncing with upstream) conflicts, the table tells you what the conflict is about and how to resolve it. If you edit an upstream file and it is not in the table, add it — an unlisted divergence is a bug.

New files that upstream does not have (this file, NOTICE, future packages/codev-*) never conflict and are not tracked here.

Divergence policy

Features go, in order of preference:

  1. Config / plugins / skills / agents — in the Codev Hub repo or config packages; zero merge cost.
  2. New workspace packages (packages/codev-*) — additive; near-zero merge cost.
  3. Edits to upstream files — last resort. Every such file must be listed in the Changed upstream files table with a reason and a conflict-resolution strategy.

Syncing with upstream

Syncing is manual and local — there is no CI workflow. main carries fork commits only (history was truncated; it shares no ancestry with upstream), so git merge upstream/... does not work. Instead, apply upstream's changes as a 3-way patch between release tags. The tag the fork is currently on is the version in packages/opencode/package.json.

git fetch upstream --tags        # also brings in the blobs `git apply -3` needs
git checkout -b sync-upstream main
# FROM = current version in packages/opencode/package.json; TO = target release
FROM=v1.17.15 TO=v1.17.16

# Drop the fork's revision suffix first. Upstream's manifests carry the plain
# version at FROM, so while ours read 1.17.15-2 every one of their version hunks
# conflicts (~29 manifests); matching FROM lets them apply clean and land on TO
# by themselves. Skip if the fork is already on the plain value — `bump` refuses
# a no-op. `git add` is required: `git apply --index` rejects unstaged changes
# with "does not match index", and staging folds this into the sync commit.
bun bump ${FROM#v}
git add -A

# `git apply` is atomic: hunks against files the fork deleted (locales, the
# docs packages — see the table below) hard-error and roll back the entire patch,
# so filter them out first. A path is skipped only if it existed in FROM and is
# deliberately absent from HEAD; files new in TO still come through.
# A file upstream DELETED in TO that the fork has MODIFIED also hard-errors
# (delete/modify has no three-way resolution), so list those too and handle
# them by hand after the apply (usually: take the deletion, then re-apply the
# fork's change wherever upstream moved the code).
git diff --name-only --diff-filter=D $FROM $TO |
  while IFS= read -r f; do
    git cat-file -e "HEAD:$f" 2>/dev/null && ! git diff --quiet $FROM HEAD -- "$f" && printf '%s\n' "$f"
  done > /tmp/delete-modify.txt

git diff --name-only $FROM $TO -- . ':(exclude)bun.lock' \
    ':(exclude,glob)packages/web/src/content/docs/*/**' |
  while IFS= read -r f; do
    git cat-file -e "$FROM:$f" 2>/dev/null && ! git cat-file -e "HEAD:$f" 2>/dev/null ||
      printf '%s\n' "$f"
  done | grep -vxF -f /tmp/delete-modify.txt |
  tr '\n' '\0' | xargs -0 git diff --binary $FROM $TO -- | git apply -3 --index

# Conflicts (files listed as "U" / left with markers): see the Changed upstream
# files table for each file's resolution strategy, then `git add` them.

# Renames leave stale sources behind: `--name-only` lists only a rename's NEW
# path, so the pathspec-limited patch regenerates it as an addition and never
# deletes the old file. Sweep for leftovers and `git rm` them:
git diff --name-status -M $FROM $TO | awk '$1 ~ /^R/ {print $2}' |
  while IFS= read -r old; do [ -f "$old" ] && echo "stale rename source: $old"; done

bun install                      # regenerate bun.lock, never patch it
bun turbo typecheck
git commit -m "chore: sync upstream $TO"

git apply -3 leaves ordinary conflict markers where our divergences overlap upstream changes — the same conflict surface the old merge had.

The extra packages/web exclude keeps upstream's translated docs from reappearing: the deleted-path filter only skips paths that existed at FROM, so a locale file new in TO would otherwise come through. The same hole applies to any other file upstream adds under packages/web/ — after applying, check git status for new files there outside src/content/docs/*.mdx and git rm them.

Then port the doc changes. packages/web/src/content/docs/*.mdx is a merge base, not a published artifact — nothing in this repo builds it. The docs users actually read live in codev-landing-page (content/docs/code/{en,vi}), converted to that site's MDX pipeline, so the two cannot be merged mechanically:

git diff $FROM $TO -- packages/web/src/content/docs   # what upstream actually changed

Carry the substance of that diff into the landing-page repo by hand, in both locales, and skip the pages that repo does not publish (zen, go, share, ecosystem, enterprise, custom-tools, sdk, server, plugins).

Keep history single-author. Sync commits are authored only by whoever runs the sync (a git apply'd patch carries no upstream authorship), which is why the repo's contributor list stays clean. If you ever squash-merge a branch containing commits by others, GitHub adds Co-authored-by: trailers that re-add those people as repo contributors — strip them from the squash message first, or use a plain merge.

Changed upstream files

File Change Why On conflict
packages/core/src/global.ts, packages/core/test/global.test.ts, packages/opencode/test/cli/mcp-add.test.ts const app = "opencode" → "codev"; test expectations tmpdir()/opencode → tmpdir()/codev and .config/opencode/ → .config/codev/ Config/data/cache/state dirs under ~/.config/codev etc.; upstream tests assert the literal dir name. Keep ours (the one constant + expectations); take upstream's surrounding changes.
packages/opencode/src/index.ts, packages/opencode/test/cli/error.test.ts, packages/opencode/test/cli/help/help-snapshots.test.ts, packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap scriptName("opencode") → "codev"; show() prefix check "opencode " → "codev "; registered the fork-only ConfigureCommand (cli/cmd/configure.ts, new file), CodegraphCommand (cli/cmd/codegraph.ts, new file), UploadCommand (cli/cmd/upload.ts, new file — codev upload: pushes session usage metrics to the analysis backend now (the same push every launch runs in the background); --logs switches to the standalone codevhub upload twin that exports sessions and ships the logs, the only path that uploads logs; --force to re-send unchanged sessions, --diagnostics to print the last run's diagnostic trail (with --logs: the last log upload); its inline SSO prompt now lives in cli/cmd/sso-prompt.ts, new file, shared with codev skill push), and SkillHubCommand (cli/cmd/skill.ts, new file — `codev skill search pull push
package.json (root) name opencode → codev-code; repository.url → github.com/quickbeard/codev-code; added release script delegating to packages/opencode; added bump script pointing at the fork-only script/bump-version.ts; removed the sso, dev:stats, and random scripts; added the lint-staged devDependency and a lint-staged config block ("*": "prettier --write --ignore-unknown") driving the fork-only .husky/pre-commit The root manifest carries the fork's name (nothing reads it — root is private and unpublished; packages/script reads only packageManager — so this is cosmetic and also removes the name collision with the packages/opencode workspace member, whose name MUST stay opencode: build.ts derives bun compile targets and dist dir names from it). bun run release and bun run bump work from the repo root. sso logs into upstream's AWS org (--sso-session=opencode) and dev:stats runs inside an sst shell against upstream's production stage — neither works without anomalyco credentials; random is inert cruft (echo 'Random script'). lint-staged keeps staged files at Prettier's fixed point on commit, so the formatting drift PR #261 had to clean up cannot silently reaccumulate; --ignore-unknown skips files Prettier has no parser for (bun.lock, .husky/*, binaries) and .prettierignore still exempts DIVERGENCES.md and the docs merge base. Keep our name + release/bump lines and the removals (drop any new upstream-infra-only scripts too); take upstream's other fields.
packages/opencode/package.json bin key opencode → codev (shim path ./bin/opencode unchanged); added release script; added dependencies "@codev/brand": "workspace:*", "@codev/gateway": "workspace:*" (the latter for the gateway-key migration's shared-auth-file read, see the config.ts migration row), and "@codev/upload": "workspace:*" (codev upload and the launch-time stats push — see the stats-push wiring row) Installed command is codev — matches the CLI's scriptName and help text (the hub's command is codevhub; it relinquished the codev bin in codev-ai ≥ 0.4.0). bun run release publishes the version in this file (upstream-synced; script/release.ts is fork-only). Keep our bin key, release line, and dependency lines; take upstream's other fields including version.
Stats-push wiring: packages/opencode/src/cli/cmd/tui.ts, packages/opencode/src/cli/cmd/run.ts, packages/opencode/src/cli/cmd/debug/index.ts tui.ts + run.ts: spawnStatsDaemon() (from the fork-only @codev/upload package) fire-and-forgotten at handler start — spawns a detached, unref'd, stdio-ignored codev debug stats child. debug/index.ts: registered the fork-only StatsCommand (debug/stats.ts, new file), the entry that child runs. Every agent launch pushes per-session usage statistics (real tokens, cost, tool calls, lines of code, hourly buckets) to the analysis backend in the background; the reports read those instead of re-deriving numbers from uploaded markdown. Logs are never uploaded from a launch — only codev upload --logs uploads them; plain codev upload runs the same stats push interactively. All gating (opt-out env CODEV_DISABLE_METRICS, signed-in check, the agent-only stats lockfile) lives inside @codev/upload — the wiring is one guarded call per entry point that can never block or fail the launch. Keep the import + spawnStatsDaemon() line in both handlers and the debug registration; take upstream's surrounding changes.
Desktop stats push: packages/desktop/src/main/sidecar.ts, packages/desktop/src/main/server.ts, packages/desktop/src/main/index.ts, packages/desktop/package.json sidecar.ts: after Server.listen succeeds, fire runStartupStats() (from the fork-only @codev/upload, statically imported so electron-vite bundles it), and handle a {type: "stats", servers?} sidecar command that re-runs it — runs are chained on an in-process promise (the stats lockfile makes a concurrent run skip, not queue, so an unchained sandbox run posted right after startup's local run would silently drop its work sessions). server.ts: SidecarListener gains stats(servers?) (posts that command with structured-clone-safe ServerSource records; no-op once exited), and createSidecarEnv sets CODEV_DESKTOP_VERSION from app.getVersion() so the push can report the desktop build (the utility process has no app). index.ts: sandboxStatsSources() maps every ready sandbox to a source (runtime.url/username/password + config.projectDir, the HOST dir, so a project's local and sandboxed sessions land in one analysis bucket); a sandboxes.subscribe diff fires server?.stats(sources) whenever a sandbox newly reaches ready (its whole history becomes readable at that moment), with a post-initialize catch-up for sandboxes ready before the sidecar listened; the gateway-sso onConfigured hook also calls server?.stats(sandboxStatsSources()) so a fresh sign-in sends the backlog (work sessions included) immediately; and @codev/upload/nudge's shouldNudgeSignIn() is read once at startup into a one-shot let that registerGatewaySsoIpcHandlers' second argument hands to the first renderer that asks (see the gateway key recovery row), which raises the "Usage reporting is paused" toast — the desktop counterpart of the TUI toast, every launch until sign-in. Consumed rather than re-read per request so the answer cannot flip mid-session and so a second window does not repeat a toast the user is already looking at; it replaced an OS Notification, which carries no action button outside macOS and on Linux depends on a notification daemon that may drop it entirely. package.json: "@codev/upload": "workspace:*" under devDependencies — deliberately, like @codev/gateway: electron-vite externalizes dependencies into the (unshipped) runtime node_modules, and only devDependency workspace packages get bundled. The desktop never uploads session logs (there is no menu item or automatic trigger for it); sandbox work sessions contribute statistics only. The desktop runs the server in-process under Electron's Node with no codev CLI in the bundle, so the CLI's detached codev debug stats spawn cannot cover it; running the push inside the sidecar utility process (never Electron main, which is UI-latency sensitive) reuses the whole pipeline — same store (the sidecar only overrides XDG_STATE_HOME), same shared auth file, same stats lockfile as the CLI. The #sqlite bun/node conditional import inside @codev/upload (same pattern as the server core's #db) is what lets the identical package run on bun:sqlite in the CLI binary and node:sqlite in the sidecar. Keep the sidecar stats command + startup trigger, the listener's stats(), the CODEV_DESKTOP_VERSION env line, the onConfigured call + the startup nudge flag, and the devDependency placement; take upstream's surrounding changes.
bun.lock Mirrors the bin key rename above, the fork-only workspace packages/deps (@codev/gateway, @codev/brand, @codev/upload), and the fork-only lint-staged devDependency Lockfile records workspace bin mappings and workspace deps. Known quirk: the root workspace stanza still says "name": "opencode" — bun never syncs the root manifest's name into an existing lock (verified: neither bun install nor --lockfile-only rewrites it); it self-corrects only on a from-scratch regeneration and is harmless meanwhile. Don't hand-edit it. Regenerate with bun install; never hand-merge.
.prettierignore Appended DIVERGENCES.md and packages/web/src/content/docs/ (each with a comment saying why) Prettier realigns every row of this file's changed-files table, so a one-row edit churns all ~120 rows — noise in the one file that records upstream-conflict guidance, and a guaranteed conflict on the next sync. Nothing in the repo formatted it before (the pre-push hook only runs bun typecheck), so this pins the status quo rather than changing it. The docs directory is the upstream merge base (see packages/web/FORK-DOCS.md): nothing builds it, its whole job is to surface upstream doc diffs during a sync, and formatting it only adds conflict surface — plus Prettier's MDX printer mangles index.mdx, collapsing the fenced code blocks inside <TabItem> into inline text. Keep our lines; take upstream's other entries.
packages/tui/package.json Added dependencies "@codev/gateway": "workspace:*", "@codev/brand": "workspace:*", and "@codev/upload": "workspace:*" (the latter's /nudge + /spawn subpaths only — sqlite-free modules for the usage-reporting sign-in toast and the post-configure stats-push respawn) The fork's gateway configure dialog imports SSO/backend/config-block logic from the fork-only packages/codev-gateway; the "Open docs" command takes its URL from DOCS_URL in the fork-only packages/codev-brand instead of hardcoding the host. Keep our dependency lines; take upstream's other fields.
packages/app/package.json Added dependency "@codev/brand": "workspace:*" Every docs link the app opens in the browser takes its base from DOCS_URL in the fork-only packages/codev-brand (see the docs-site links row) instead of hardcoding the host. codev-brand is a zero-dependency leaf that uses only atob, so it is safe in client runtime code. Keep our dependency line; take upstream's other fields.
packages/tui/src/feature-plugins/home/tips-view.tsx NO_MODELS_TIP points at /configure (CoDev gateway) instead of /connect; tip strings rebranded — built-CLI examples opencode <cmd> → codev <cmd> (serve, upgrade, agent create, github install, debug config) and product prose "OpenCode" → "CoDev Code". Dropped the docker run … ghcr.io/anomalyco/opencode tip and the /connect tip that advertised "OpenCode Zen" (a generic /connect tip already exists). GitHub-integration tips rebranded to the fork's trigger: the two /opencode tips and the /oc PR-review tip now say /codev (matches the rebranded github.handler.ts defaults). The tips name the fork's CLI (codev), product, and /codev GitHub trigger; no fork docker image (see web-docs install row); Zen is upstream's hosted gateway. The opencode.status command id stays (internal identifier, not user-facing). Keep our tip text and the docker-tip and Zen-/connect-tip deletions. Rebrand any new upstream opencode <cmd>/"OpenCode"//opencode-trigger tip; drop any re-added docker/brew/curl install tip.
packages/tui/src/feature-plugins/sidebar/footer.tsx Deleted the dismissable "Getting started" card (free-models pitch + /connect pointer + dismissed_getting_started KV flag); the sidebar footer keeps only the cwd path display The card advertises upstream's hosted free Zen models — which the fork hides — and points at /connect while first-run setup routes through /configure; the fork's startup gate covers onboarding. Keep the card deleted; take upstream's changes to the remaining path display.
packages/tui/src/ui/dialog.tsx Added a persistent store flag (with setPersistent/persistent accessors): escape and the click-outside backdrop no-op while set, and ctrl+c dispatches app.exit.confirm (base-mode-only, so suppressed under modal) instead of closing; every replace/clear resets it to false The CoDev gateway configure flow is a non-dismissable first-run gate; ctrl+c stays the quit escape hatch (double-press — see the double-Ctrl+C row). Default-false keeps every other dialog dismissable. Keep our persistent flag, the escape/backdrop guards, the ctrl+c app.exit.confirm dispatch, and the reset lines; take upstream's surrounding changes.
packages/tui/src/ui/dialog-select.tsx Added a persistent? prop that hides the top-right esc hint A non-dismissable select shouldn't advertise esc (the dialog store's persistent flag blocks the actual close). Keep our prop + the Show wrapper around the esc label; take upstream's surrounding changes.
packages/tui/src/ui/dialog-prompt.tsx Added persistent? (hides the esc hint + DialogPrompt.show re-arms the store flag), onInput? (fires on textarea content change), and ref? props The configure flow's prompts (SSO login, manual credentials) are non-dismissable, and the SSO view watches onInput for the lone-c copy shortcut on the paste-back link (needs the ref to clear the field). Keep our three props, the Show around the esc label, the textarea onContentChange, and the .show re-arm; take upstream's surrounding changes.
packages/tui/src/ui/dialog-prompt.tsx, packages/tui/src/config/keybind.ts, packages/tui/src/app.tsx Ctrl+V paste in dialog prompts. keybind.ts: added "dialog.prompt.paste" definition (ctrl+v, preventDefault: false, no CommandMap entry — dotted keys map to themselves). dialog-prompt.tsx: a dialog.prompt.paste command in the existing priority-1 binding layer reads useClipboard().read?.() and inserts text/plain content (newlines stripped, matching the single-line InputRenderable paste — dialog prompts are single-line fields and some upstream consumers store the value untrimmed); the command list is gathered alongside dialog.prompt.submit. app.tsx: TuiInput.clipboard?: ClipboardService passed to <ClipboardProvider value=…> so tests inject a deterministic clipboard. Covered by the ctrl+v test in the fork-only packages/tui/test/custom-provider.view.test.tsx. Many Windows terminals deliver ctrl+v as a key event instead of a bracketed paste, and upstream's clipboard-reading prompt.paste command targets only the main session prompt — so dialog fields (API keys in /connect, the custom-provider flow) could not paste at all (upstream issue anomalyco/opencode#39837). Bracketed paste still goes through the textarea's built-in default handler; the binding layer's !props.busy guard disables paste while busy. Keep the keybind definition, the paste command + gathered binding list, and the clipboard input plumbing; take upstream's surrounding changes. Sent upstream as anomalyco/opencode#39844 (identical code) — once it lands in a synced release this row collapses to nothing; if upstream ships a different dialog paste fix, prefer theirs and drop this row.
packages/tui/src/clipboard.ts, packages/tui/src/component/prompt/index.tsx read()'s win32/WSL branch reads the text clipboard with powershell.exe … [Console]::Out.Write([string](Get-Clipboard -Raw)) (UTF-8 OutputEncoding) right after the existing image probe, instead of falling through to clipboardy. clipboardy's Windows backend is the only one with no PATH-resolved fallback — it spawns ../fallbacks/windows/clipboard_x86_64.exe, resolved from import.meta.url. Inside our compiled single-file binary that resolves under the bunfs root (B:/~BUN/root/) and the .exe is not embedded (verified: codev-code-windows-x64@1.18.4-6 ships only bin/codev.exe), so every text read failed with ENOENT and was swallowed by the .catch. clipboard.read() returning undefined silently broke ctrl+v in the main prompt (prompt.paste), in dialog prompts (dialog.prompt.paste, see the row above), and in the empty-bracketed-paste fallback. Copy was unaffected (copyCommand already shells out to PowerShell), as was image paste. Console.Out.Write bypasses PowerShell's formatting pipeline so no trailing newline is appended. Paired with a packages/tui/src/component/prompt/index.tsx change: the prompt.paste command now toasts when the clipboard read yields nothing, instead of returning silently — that silence is why a completely dead paste path went unreported for so long. Keep the PowerShell text read as long as we ship a compiled binary. If upstream drops clipboardy or vendors the Windows helper next to the executable, prefer theirs and drop this row.
packages/tui/src/terminal-win32.ts, packages/opencode/src/cli/cmd/debug/index.ts terminal-win32.ts: added a win32ConsoleMode() export returning the console input mode read through the guard's own kernel32 handle (undefined under the same conditions that stop win32InstallCtrlCGuard installing). debug/index.ts: registered the fork-only ConsoleCommand (codev debug console, packages/opencode/src/cli/cmd/debug/console.ts). Windows users whose console keeps ENABLE_PROCESSED_INPUT set get ctrl+c as a console break rather than a keypress, which strands the cmd.exe shims in a hub launch chain at "Terminate batch job (Y/N)?" and looks like a frozen terminal. codev debug console reads the mode, installs the real guard, re-reads to prove the bit actually clears, then unhooks and re-reads — so an affected machine reports itself instead of needing a maintainer to bisect it. codevhub doctor relays the JSON form as its console-ctrl-c check. Keep the win32ConsoleMode export and the one-line command registration; take upstream's surrounding changes. Both collapse to nothing if upstream ever ships its own console diagnostic.
packages/tui/src/terminal-win32.ts, packages/tui/src/app.tsx terminal-win32.ts: added a win32RasterFontConsole() export (kernel32 GetCurrentConsoleFontEx via the existing dlopen table): true only when stdout is a win32 TTY, none of WT_SESSION/TERM_PROGRAM/TERM are set (conhost sets none of them; Windows Terminal, VS Code, mintty, ssh each set one), and the current console font lacks TMPF_TRUETYPE. app.tsx: an onMount next to the other startup toasts shows a one-shot warning toast when it returns true, telling the user to use Windows Terminal or a TrueType console font. Legacy conhost with a raster font can only draw OEM-code-page characters: the display path best-fit converts everything else to "?" at draw time (chcp/SetConsoleOutputCP docs), so Vietnamese/CJK users see corrupted prompts and responses even though the buffer and the data are correct. The app cannot fix the rendering, so it warns instead of corrupting silently. Keep the win32RasterFontConsole export and the warning-toast onMount; take upstream's surrounding changes. Drop both if upstream ships its own legacy-console/raster-font warning.
packages/opencode/script/build.ts Platform packages' package.json name codev-code-<platform> (dist dir names unchanged — the bun compile target derives from them); compiled binary emitted as bin/codev (was bin/opencode; smoke-test path follows); win32 targets additionally stage ripgrep at bin/rg.exe (RG_VERSION — "latest" resolves the newest GitHub release per build via the releases API, an exact version pins its release tag; asset zip extracted in-memory with @zip.js/zip.js so no unzip/powershell host dependency, magic-byte-checked against proxy block pages; one release lookup, and x64/x64-baseline share one download per arch) npm publishing under the fork's names; the binary inside platform packages carries the fork's name (postinstall.mjs copies it into the codev bin shim). The Script.release upload block stays upstream-shaped and unused — the fork's .github/workflows/release.yml packages and uploads GitHub-release artifacts itself. Bundling rg makes a bare npm i -g codev-code self-sufficient on Windows, where fff is disabled so file search depends entirely on rg and corporate networks block the runtime GitHub download (see the Windows file-search row for the resolution side). RG_VERSION defaults to "latest": the build machine needs GitHub access anyway, and floating leaves no version to keep in sync — binary.ts's pinned VERSION governs only the runtime download fallback (the mirror hosts just that version) and is independent of the bundled copy. Keep our name: line, bin/codev paths, and the ripgrep bundling block; take upstream's surrounding changes.
packages/opencode/script/publish.ts PUBLISH_NAME = "codev-code" const; published-name→dir map; main pkg name + bin codev → ./bin/codev.exe (postinstall stub file renamed from ${pkg.name}.exe, matching the renamed platform binary); embedded README; no repository field in the generated package.json (npm renders it as the Repository link and derives a #readme Homepage from it — both kept off the npm page); env-guarded exit before the Docker/AUR/Homebrew section; npm publish retried up to 5 times with linear backoff, treating a version that turns up on the registry after a failed attempt as published; stale *.tgz deleted before bun pm pack; the dist manifest scan skips the generated main package directory ; the stub's message gains two lines pointing npm 12+ users at npm install -g codev-code --allow-scripts=codev-code, since npm 12 blocks install scripts on global installs by default and the stub is exactly what they then run npm-only publishing under the fork's names (unscoped, public on npmjs); the installed command is codev; upstream's other channels target anomalyco infra; the npm page should not link the fork's GitHub repo. Releases run from a laptop rather than CI, where the 13 concurrent 40-60MB uploads regularly time one package out (npm error FETCH_ERROR) and abort the release mid-way; resuming such a release must not make the main package depend on itself. bun pm pack writes the tarball into the directory it packs, so the retry path it enables would otherwise bundle the previous run's tarball into the next one, doubling every package (~55MB → ~112MB) and making the timeouts far worse. Keep our names, description, bin mapping, omitted repository, retry loop + early exit; take upstream's npm-section changes above the exit. If upstream adds its own publish retry, drop ours for theirs.
packages/opencode/bin/opencode base prefix opencode- → codev-code- Runtime platform-binary lookup must match the published names. Keep our prefix.
packages/opencode/script/postinstall.mjs base prefix opencode- → codev-code-; source/target binary names opencode/opencode.exe → codev/codev.exe (matches build.ts's bin/codev and publish.ts's bin/codev.exe stub); temp-dir prefix and failure message rebranded; best-effort copyRipgrep copies the Windows packages' bundled bin/rg.exe next to the codev binary, on both resolution paths (in the temp npm-install path before its finally cleanup deletes the package); installPackage spawns the fallback npm install through cmd.exe /d /s /c npm … on Windows (plain npm elsewhere) and takes a failures array; verifyBinary returns null or a one-line reason (spawn error, exit code/signal, first stderr line — stderr is piped, not ignored); main collects a per-package reason for every failed step (not installed → fallback fetch → --version) and appends a What was tried: list to the final error; firstLine helper Install-time platform-binary fetch must match the published package and binary names. The rg copy is what puts the bundled binary where core's ripgrep resolution looks (path.dirname(process.execPath)); it must never fail the install — the agent still has PATH/cache/download resolution without it. Keep our prefix, binary names, the copyRipgrep calls, the cmd.exe spawn, and the failure reporting; take upstream's surrounding changes. The cmd.exe spawn fixes an upstream bug (Node ≥ 18.20.2/20.12.2 refuses to spawn npm.cmd without a shell, so the fallback silently never ran on Windows) — drop it if upstream ships its own fix.
packages/tui/src/logo.ts Pixel glyphs "open"+"code" → "co"+"dev"; added a brand export (navy #19224c / red #ee0033, the landing page's --color-brand-navy/--color-brand-red), a wordmarkHalves export (each row's "co" and "dev" halves with the shadow marks substituted to glyphs, so each half can take its own brand color), the composed wordmark (plain lines, built from wordmarkHalves) and ansiWordmark (bold, no shadow: "co" in the terminal's default foreground, "dev" in brand red truecolor) exports for CLI banners; added a badge export (the "c" of the wordmark — logo.left rows 1-3 sliced to the first glyph, with the _ shadow-fill marks stripped to spaces so it renders as a flat outline) for the --mini splashes Branded wordmark art; ansiWordmark feeds the CLI help banner and session epilogue. Keep our glyphs + exports; take upstream's surrounding changes.
packages/tui/src/component/logo.tsx Renders the lowercase codev wordmark from wordmarkHalves — one bold <text> per half, no per-char shadow cells — with "co" in brand.navy on light / theme.text on dark and "dev" in brand.red, instead of upstream's two-tone "open"+"code" Banner is pixel-identical to the landing page wordmark (codev-landing-page app/components/PixelWordmark.tsx: text-brand-navy dark:text-foreground + text-brand-red), so the marketing site and the TUI read as one brand. Deliberately fixed brand colors rather than the theme's secondary, and no drop shadow — the landing wordmark has neither. Keep our version wholesale. If upstream restructures the component, re-apply the two-half brand coloring rather than reverting to a theme color.
packages/ui/src/components/logo.tsx, packages/ui/src/components/logo.css Logo renders the lowercase "codev" wordmark instead of upstream's "opencode": the wordmarkHalves pixel glyphs of packages/tui/src/logo.ts resolved to a 24x6 grid at one unit per pixel (viewBox 0 0 24 6, was 0 0 234 42), as two <path>s — data-slot="logo-wordmark-co" and …-dev — with no drop-shadow paths. Colors come from two custom properties defined in logo.css on [data-component="logo-wordmark"]: --logo-wordmark-dev is always brand red #ee0033, --logo-wordmark-co is brand navy #19224c on light and var(--icon-strong-base) on dark. The dark swap is declared three times — under prefers-color-scheme: dark for surfaces that never stamp a theme, then under both [data-color-scheme="light"] and [data-color-scheme="dark"] so the app's explicit choice (packages/ui/src/theme/context.tsx sets it on <html>) beats the OS preference in both directions. Brand hexes are literals, not theme tokens — same reasoning as the TUI component row. Mark and Splash likewise drop upstream's square "o" for the codev "C": the block art █▀▀▀ / █ / ▀▀▀▀ on a 4x5 grid with a one-unit stroke — the same geometry packages/desktop/icons/icon.svg and the codev --mini badge (badge in packages/tui/src/logo.ts) draw — as one full-bleed <path> each, data-slot="logo-mark-c" at viewBox 0 0 16 20 and logo-splash-c at 0 0 80 100. Upstream's second path on each glyph, which shaded the lower half of the "o"'s enclosed counter in --icon-weak-base / --icon-base, is dropped: the C is open on the right so there is no counter to shade, and the TUI badge is a flat outline for the same reason. The two split on color. Splash is brand-locked through --logo-splash: #ee0033, declared once on [data-component="logo-splash"] with no dark-mode swap — red clears 4:1 against both the light and dark background-base, unlike the wordmark's navy half. Mark keeps var(--icon-strong-base) because two of its three call sites are opacity-10 watermarks in the session side panel, where brand red reads as a pink wash instead of a neutral texture. The web/desktop banner is the same art the TUI home banner and the landing page wordmark draw, so every surface reads as one brand. Same for the marks: the desktop boot splash, the app icon, the landing page icon and the --mini badge are one glyph in one red, so the splash should not be the only surface still drawing upstream's "o" in theme grey. Keep our version wholesale. If upstream restructures the component, re-apply the two-half brand coloring rather than reverting to --icon-* tokens, and re-apply the C rather than taking back the "o". Keep Splash brand-locked and Mark tokenized — do not "unify" them, the split is load-bearing for the opacity-10 watermarks.
packages/ui/src/v2/components/wordmark-v2.tsx (+ fork-only wordmark-v2.css) WordmarkV2 — the watermark behind the v2 new-session composer (packages/app/src/components/session/session-new-design-view.tsx) — draws the codev wordmark from the same 24x6 pixel grid as Logo instead of upstream's 720x129 "opencode", as two <path>s (data-slot="wordmark-v2-co" / …-dev). Upstream's watermark treatment is kept verbatim: the 0.6 outer opacity and the alpha mask whose gradient fades the mark out over its bottom half (converted to the new viewBox: gradient y1 68/129 → 3.163/6). "co" keeps fill="currentColor" so it still follows the caller's text-* class; "dev" takes brand red #ee0033. Per-half opacities live in the new wordmark-v2.css, following the one-CSS-file-per-v2-component convention: co is 0.112 (upstream's own 0.16 × 0.7 nesting, flattened), dev is boosted to 0.224 on light and 0.336 on dark via --wordmark-v2-dev-opacity, declared under prefers-color-scheme and both [data-color-scheme] values like components/logo.css. The boost is deliberate and measured: at upstream's weight a saturated red composites to within a few levels of the grey "co" half against the dark background and the two-tone disappears entirely, while the same value already reads pink against the light one. The component is currently not rendered — the new-session view drops it (see the row below) — but it stays rebranded and exported so reinstating the watermark does not bring the upstream mark back with it. Keep our version wholesale, including the per-theme dev opacity. If upstream retunes the watermark opacities, re-derive the dev boost from the new base rather than adopting a single flat value.
packages/ui/src/theme/themes/oc-2.json One character: the light variant's "icon-weak-base": "C7C7C7" → "#C7C7C7". Not a brand change — an upstream typo, reported as anomalyco/opencode#41503. Without the # it is still a valid custom-property declaration, so it parses cleanly and wins the cascade over the static --icon-weak-base in packages/ui/src/styles/theme.css (resolved tokens are injected into a <style> element by theme/context.tsx). It only fails at var() substitution, where an invalid computed value falls back to the inherited value for inherited properties and the initial value otherwise — so the failure looks different at every call site. Measured on the default light theme: color: consumers paint their parent's color instead of the weak grey, and background: / fill: consumers paint nothing. That blanks the dialog-release-notes carousel indicators and the neutral diff-changes blocks outright, and renders the file-tree, settings-keybinds and settings-models icons at full strength. oc-2 is the only one of the 37 themes that declares this token literally and the only malformed one — the rest take the derived neutral[6] — and it is the default, so this is the default light-mode experience. Dark (#343434) was always correct. Five consumers are visibly wrong on the default light theme and the fix is one character, so there is no reason to wait for upstream. Drop this row and take upstream's line once the issue is fixed there. Until then keep ours, and take any upstream change to this value as long as it starts with #.
packages/app/src/components/session/session-new-design-view.tsx, packages/app/src/pages/new-session.tsx Drops the WordmarkV2 watermark from the v2 new-session screen and centres the composer. Upstream absolutely positions the content block at top-[25.375%] to leave room for the wordmark above it; with the mark gone that offset just parks the composer high in an empty pane, so the outer element becomes flex items-center justify-center over size-full (carrying the px-6 inset across) and the mt-8 spacer under the wordmark goes away. The pane also takes the same raised card every session sits on: bg-v2-background-bg-deep → bg-v2-background-bg-base in the view, and new-session.tsx's rounded-[10px] wrapper gains bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] — the exact pair SessionPanelFrame (pages/session.tsx) applies with newLayout raised. Verified equal at runtime: same background, same eight-layer shadow, same 10px radius. The wordmark reads as clutter on the desktop new-session screen; without it the pane should be balanced, not top-weighted. And with the sidebar always on screen, upstream's bg-deep made the draft screen read as an extension of the window chrome while every session floated on a card — the two screens have to match. Keep our centred layout and the card. If upstream restyles this view, re-apply the drop + centring rather than restoring top-[25.375%], and re-check the background against whatever SessionPanelFrame then uses rather than hardcoding bg-base. Reinstating the watermark means restoring the offset too, or it will sit centred with the composer.
packages/app/src/pages/home.tsx, packages/app/src/pages/error.tsx, packages/enterprise/src/routes/share/[shareID].tsx Logo width utilities retuned for the codev wordmark's 4:1 box (upstream's is 5.57:1): home md:w-xl → md:w-104, error w-58.5 → w-42, share (both call sites) w-58.5 → w-42 Each value is the width at which the new wordmark renders at exactly the height the upstream one did, so no surrounding layout shifts. Keep our widths; take upstream's surrounding changes. Recompute rather than restore if upstream changes the size.
packages/app/src/components/titlebar.tsx Four changes, all to the v2 titlebar branch only (useV2Titlebar()); the legacy branch below it is untouched. (1) The Home button (and its home.toggle command, mod+b) opens a new session instead of toggling between / and the last active tab: tabs.toggleHome(...) becomes a goHome that selects an already-open draft if there is one and otherwise falls through to the existing openNewTab() project-resolution chain. Its pressed / aria-pressed state keys on layout.route().type === "draft" rather than "home". The icon, label (home.title) and keybind are unchanged, as is tabs.toggleHome itself, which is simply no longer called. (2) <ChannelIndicator> is dropped from that row and the component gains an export, so the sidebar can render the DEV/BETA badge at its bottom instead; Titlebar keeps its debugTools prop because the legacy branch still renders the badge inline. (3) Two buttons added to the row: a sidebar collapse toggle (sidebar-right icon, data-action="titlebar-sidebar-toggle", pressed while open) as its first child, ahead of the `windows() linux() WindowsAppMenu; and a Search button (magnifying-glass, data-action="titlebar-search") immediately after Home, navigating to /?focus=search. The toggle reads the fork-only useAppSidebar()and is wrapped in, which is what keeps it out of the legacy shell. Labels reuse the existing command.sidebar.toggleandhome.sessions.search.placeholderkeys — no new i18n. (4)openNewTabroutes all five of its project-resolution branches through a localopenDraftFor(...), which selects the draft already open for that server + directory — via the fork-only findOpenDraftinpages/home-draft-reuse.ts— and only callstabs.newDraftwhen there is none. The+button gainsdata-action="titlebar-new-tab"` so e2e can address it without colliding with the sidebar row, which carries the same accessible name. The model read off the active tab still seeds a new draft only; a reused one keeps its own, as selecting its tab by hand would.
packages/app/src/context/tabs.tsx newDraft inserts with tabs.unshift(tab) instead of tabs.push(tab), so a draft opens at the head of the tab strip. Session tabs (addSessionTab) still append, and nothing else about draft handling changes. A draft is where you start a session, so it belongs at the front of the strip rather than behind every session already open — with several tabs open the New session tab was scrolling off the right-hand end. Keep the unshift. On conflict take upstream's newDraft and swap the insert back. Note the knock-on: promoteDraft replaces the draft at its own index, so a promoted session keeps the head position and sessions accumulate newest-first. That is intended — the tab you are working in should not jump across the strip the moment you send the first message — but if it is ever unwanted, move the tab in promoteDraft rather than reverting this, or the New session tab goes back to the end.
packages/app/src/pages/layout-new.tsx (again) Mounts the fork-only <LaunchNewSession />, which on desktop decides one time per boot where the window lands: a window with tabs open on the showing side resumes that side's last session (layout.home.lastSession, validated with sessionAlive the way the Code/Work switch does), falling back to a session tab already open when that history is unusable; only a window with nothing open lands on a new-session draft (reusing an existing empty draft rather than stacking one up per launch). The chain lives in the exported pure launchLanding, tested in the fork-only packages/app/test-browser/launch-landing.test.ts, because the desktop-only path cannot be driven from the web e2e harness — the same split workspace/side.ts's folderEntry uses. It pins its decision to the pathname the window came up on and disarms as soon as anything navigates, so it can never hijack a later visit to / — the sidebar's "View all" links there. Desktop-only: in the browser there is no "launch" distinct from "navigate to /", so redirecting would make the recents screen unreachable by URL and would break two packages/app/e2e specs that goto("/"). Launching should put you in front of a prompt, the way Claude Desktop does, rather than on a list. This supersedes the desktop's restore-last-screen behaviour (getLastActiveUrl, packages/desktop/src/renderer/index.tsx); the previous session stays one click away in Recents and its tab is still restored. Keep the mount. If upstream restructures this layout, re-add it. Do not "simplify" the boot-window guard into a plain location.pathname === "/" check — that is the bug it exists to prevent.
packages/app/src/pages/layout-new.tsx, packages/app/src/pages/home.tsx, packages/app/src/i18n/{en,es,vi}.ts Persistent app sidebar. Upstream's project column is an <aside> owned by NewHome, so it exists only on /. It becomes app chrome: layout-new.tsx wraps its <main> in a row flex and renders the fork-only <AppSidebar class="hidden lg:flex"> beside it, so the sidebar survives every navigation. The row components stay in home.tsx — HomeUtilityNav, HomeServerRow, HomeProjectList, HomeProjectEmpty, HomeRecentlyClosedRow and the classes HOME_PROJECT_NAV_ROW / HOME_PROJECT_NAV_LABEL / HOME_SECTION_LABEL gain an export and nothing else, so upstream edits to them still apply cleanly; the fork-only src/components/app-sidebar.tsx imports and composes them, adding only the column shell and the Recents section (HOME_SECTION_LABEL is consumed one level down, by the fork-only src/components/sidebar-section.tsx). home.tsx otherwise loses just HomeProjectColumn (dead once the sidebar owns that composition) and the NewHome selection/action block, and its grid collapses from lg:grid-cols-[280px_minmax(0,720px)] to one max-w-[760px] column. The sub-lg HomeUtilityNav mount is untouched, so the stacked mobile layout is upstream's. The persistent column itself no longer mounts HomeUtilityNav: app-sidebar.tsx renders its own copy of the Settings row (same classes/label, so the e2e Settings handle is unchanged) followed by the fork-only src/components/account-nav.tsx — a Log in row while signed out (dispatching gateway.apiKey.new) and an initials avatar while signed in whose menu holds Help (which now lives only there; the sidebar's Help row is gone by design) and Log out (see the gateway key recovery row for the logout plumbing). The state both screens need (focusedServer, projects, selectedProject, newSessionProject, projectDirectories, the session-index query, openNewSession, openProjectNewSession, openSession, selectProject, addProjects, editProject, closeProject, unseenCount, clearNotifications, chooseProject, isBackgroundOpen, buildHomeSessionRecords, HomeSessionRecord) moves to fork-only src/pages/home-model.tsx, which both callers instantiate — safe because the selection lives in useLayout().home, the index is one TanStack query deduped by key, and the server-normalizing effect is idempotent. New i18n keys sidebar.recents, sidebar.recents.new, sidebar.recents.viewAll in en + es, and sidebar.login / sidebar.logout / sidebar.account in en, es, vi for the account entry. layout-new.tsx additionally wraps its shell in the fork-only <AppSidebarProvider> (so the titlebar toggle and the sidebar share one collapse state), AppSidebar renders nothing while collapsed, and NewHome consumes a ?focus=search param — clearing it after use, the way pages/new-session.tsx handles ?prompt — to focus the search field for the titlebar's Search button. That focus ref changed from a let to a signal because HomeSessionSearch binds it from onMount, which runs after NewHome's effects, so a plain ref is still undefined when the param arrives. AppSidebarProvider also registers the sidebar.toggle command (no keybind — mod+b is Home's here, unlike in the legacy layout), which is what the app menu's View → Toggle Sidebar dispatches; only LegacyLayout registered it before, so that item did nothing on this layout. It lives on the provider, not on AppSidebar, so collapsing does not unregister the command that undoes it. Both shells can be mounted at once on /:dir routes, so the id may be registered twice there; the command registry keeps the first entry, which is the outer (new-layout) one. Every data-component/data-action hook is untouched (home-project-row, home-add-project, home-session-row, home-session-search, …) because packages/app/e2e selects on them. The desktop app should navigate like Claude Desktop — one always-visible sidebar with Projects, Recents and pinned Settings/Help — instead of losing all navigation the moment a session opens. Exporting the row components in place rather than relocating them keeps home.tsx down to ~36 added lines: upstream changes to those components merge without a conflict, which relocating them would have forfeited. Keep our structure. On conflict take upstream's home.tsx wholesale and re-apply the four edits: the export keywords, the HomeProjectColumn deletion, the createHomeModel() swap in NewHome, and the single-column grid. If upstream edits an exported row component, just take it. Never let a data-component/data-action hook drift — the e2e suite has no other handle on these rows.
packages/app/src/app.tsx AppInterface's ServerShell no longer wraps its children in a second <QueryProvider>; it renders <SharedProviders> directly. QueryProvider itself is unchanged and still installed once, by AppBaseProviders. QueryProvider builds a fresh QueryClient on every render, and it was mounted twice: once in AppBaseProviders and again inside ServerShell. GlobalProvider sits above ServerShell, so ServerSync — and the home session index cache it owns (context/global-sync/home-session-index.ts) — resolved the outer client, while everything under the router, including the sidebar and the home screen, resolved the inner one. The index that session.created/session.updated events write to therefore had no readers, and the list those events exist to keep fresh could only change when its own query refetched. In this fork that list is the persistent sidebar's Recents section, so a session stayed missing from Recents until something remounted the observer — switching Code/Work and back reassigns the observer's key, which is why that looked like the fix. Both AppInterface call sites (packages/app/src/entry.tsx, packages/desktop/src/renderer/index.tsx) already wrap it in AppBaseProviders, so the nested client was always redundant. Keep the single provider. If upstream reworks the shell, the invariant to preserve is that ServerSync and the components reading its queries resolve the same QueryClient — verify by checking that homeSessions.apply and the sidebar's sessionLoad land in one cache, not that this exact JSX survives.
packages/app/src/context/settings.tsx, packages/app/src/components/settings-general.tsx, packages/app/src/components/settings-v2/general.tsx, packages/app/src/context/settings.test.ts, packages/app/src/pages/session/composer/session-revert-dock.stories.tsx, deleted packages/app/src/components/settings-v2/interface-transition.tsx + interface-transition.stories.tsx, deleted packages/app/e2e/regression/legacy-new-session.spec.ts, packages/app/e2e/regression/session-timeline-projection.spec.ts, packages/app/e2e/performance/timeline-stability/fixture.ts The legacy layout is retired: the fork ships one layout and no way back. Upstream runs a timed migration — oldInterfaceSunset (a hardcoded date), a per-profile layoutTransitionEligible flag, a newLayoutDesigns preference and a Settings → General New layout switch — so existing users can flip between layouts until the sunset passes. All of it goes. newLayoutDesigns becomes createMemo(() => true): reading a constant rather than the persisted value is what makes a profile carrying newLayoutDesigns: false (older build, another device via sync, hand-edited store) still come up on the new layout, which suppressing the toggle alone would not. setNewLayoutDesigns, layoutTransitionAvailable, newInterfaceNoticeVisible and dismissNewInterfaceNotice are removed from the context rather than stubbed — a no-op setter would let a caller look like it was switching layouts, so re-introducing the toggle has to be a compile error. Both Settings screens (v1 settings-general.tsx and v2 settings-v2/general.tsx) lose their InterfaceSection + InterfaceNoticeSection and the <Show> blocks that mounted them; interface-transition.tsx and its story are deleted with their last caller. The sunset machinery in settings.tsx goes with it — oldInterfaceSunset, legacyNewLayoutDesignsDefault, newLayoutDesignsDefault, newLayoutDesignsUpgradeCutoff, shouldEnableNewLayout, layoutTransitionState, maximumSunsetTimeout, nextSunsetCheckDelay, resolveNewLayoutDesigns, the retirement setTimeout and the two createEffects that wrote newLayoutDesigns back to the store — along with their tests; settings.test.ts keeps only the isAppUpgrade / shouldDisplayTabsToast block. Deliberately kept: layoutTransitionEligible, layoutTransitionClassified and setOldLayoutEligible (with the desktop isOldLayoutEligible IPC that feeds them), because the tabs toast still uses eligibility to tell a fresh profile from an existing install — they no longer gate any layout. Also kept: every newLayoutDesigns() read in app.tsx and the LegacyServerLayout / LegacyHome code behind them. That shell is still mounted on /:dir routes, and leaving the branches in place keeps upstream's edits to them applying cleanly; deleting the legacy layout outright is a far larger diff for no user-visible gain. On the test side legacy-new-session.spec.ts is deleted (it asserted a redirect to a route that no longer resolves), session-timeline-projection.spec.ts drops its one settings: { newLayoutDesigns: false } pin, and the timeline-stability fixture drops the newLayoutDesigns === false branch that back-dated app-version.v1. The many specs that set newLayoutDesigns: true are untouched — the value is simply ignored now. The fork-only packages/app/e2e/regression/layout-theme-lock.spec.ts covers both locks from a seeded profile; each of its four cases was checked to fail when the matching source file is reverted to upstream's. The fork ships one layout. Leaving a switch that drops users into a layout the fork does not design for, style-test or screenshot means two UIs to maintain and support, and the upstream sunset date would have retired it anyway. Keep ours. On conflict take upstream's file and re-apply: the constant newLayoutDesigns, the four removed context members, the two deleted Settings sections. If upstream adds a new layout-preference surface, remove it too rather than wiring it up. Do not "restore" setNewLayoutDesigns to make a new upstream caller compile — delete the caller. Do not delete the LegacyServerLayout branches in app.tsx; they are load-bearing on /:dir and deliberately left as upstream's.
packages/ui/src/theme/context.tsx, packages/app/public/oc-theme-preload.js, packages/app/src/theme-preload.test.ts, packages/app/src/pages/layout.tsx, packages/app/src/components/settings-general.tsx, packages/app/src/components/settings-v2/general.tsx One theme in the web/desktop UI; light and dark are the only axis. Upstream ships 36 selectable palettes with a Settings → Appearance Theme picker and command-palette entries. The fork exposes none of them: OC-2 is the theme, colorScheme (system/light/dark) is the only appearance choice. The lock lives at normalize() in theme/context.tsx, renamed to the exported normalizeThemeID() and returning the exported SHIPPED_THEME_ID unconditionally — that one function is already on every path (provider init, the onMount migration, load, the cross-tab storage listener, setTheme, previewTheme), so a stored id from before the picker was removed, or one written by another tab, resolves to OC-2 without a second guard. It is exported (and the six call sites renamed) so theme/context.test.ts can cover the invariant directly instead of only through its call sites — the module imports cleanly under bun test because import.meta.glob is called lazily inside getFiles(). It also gives the migration for free: onMount already rewrites opencode-theme-id and calls clear() when the raw value differs from the normalized one, which now drops the stale opencode-theme-css-{light,dark} cache. ids() returns ["oc-2"] and loadThemes() loads only OC-2 instead of all 36 (it was Promise.all(themeIDs().map(load))). The bundled theme JSON, the names map and registerTheme are left alone — unreferenced data costs nothing and keeps upstream's theme edits applying cleanly. oc-theme-preload.js does the same rewrite before mount, so the first launch after upgrading does not paint the user's old palette for a frame; with no other theme reachable its cached-CSS <style> injection is gone and it only sets the mode attributes. Removed surfaces: the Theme SettingsRow/SettingsRowV2 in both Settings screens (with the …/themes docs "Learn more" link, themeOptions, the ThemeOption type and the onMount(loadThemes) that fed them), and in pages/layout.tsx the theme.cycle command with its mod+shift+t keybind, the cycleTheme helper and the theme.set.<id> command loop (with availableThemeEntries). The colour-scheme commands stay: theme.scheme.cycle (mod+shift+s), the three theme.scheme.<scheme> entries and their preview/commit wiring. i18n keys (settings.general.row.theme.*, command.theme.cycle, command.theme.set, toast.theme.title) are left in the dicts — unused keys are harmless and removing them only creates conflicts. Every fork surface — the brand, the icons, the screenshots, the docs — is drawn against OC-2. A palette picker means 36 combinations nobody verifies, and a user on Synthwave '84 files bug reports about a UI the fork never shipped. Light/dark still covers the real need. Keep ours. Take upstream's surrounding changes; re-apply the normalize() constant, the ids()/loadThemes() narrowing, the preload rewrite, and the removed rows/commands. If upstream adds a new theme-selection surface, remove it rather than wiring it up. Do not "fix" normalize() back to the oc-1 → oc-2 mapping — that mapping is a subset of what it does now. Leave the bundled theme files and names map in place; deleting them is a much bigger conflict surface for no gain.
packages/app/src/pages/session/timeline/message-timeline.tsx Session actions menu extracted. The ⋯ menu beside the session title — both renderings, the legacy DropdownMenu and the v2 MenuV2, ~145 lines — and the DialogDeleteSession closure move to the fork-only packages/app/src/components/session-actions-menu.tsx (verbatim at extraction; the v2 Delete item has since dropped its ellipsis and taken the danger colour, so do not diff the two expecting a match), and this file renders <SessionActionsMenu> instead, passing its existing openTitleEditor / share / archiveSession / deleteSession as callbacks and its trigger element out through triggerRef (the share popover still anchors to it). The extracted dialog takes a resolved title and an onConfirm rather than a sessionID, so the caller supplies sessionTitle(sync().session.get(id)?.title). The title store loses pendingRename/pendingShare: deferring Rename and Share until the menu's onCloseAutoFocus is now the component's job, since every caller needs it (a list row's inline editor is blurred by the returning focus exactly as the header's was). Also gains one fork-only createEffect that consumes consumeSessionShareRequest(sessionID()) and opens the share popover — the session rows' Share routes through it. The sidebar's Recents rows and the home session list open the same menu; three copies of four items would drift. Only this file rendered the menu, so extraction is the smallest way to share it. Keep the extraction. On conflict, take upstream's message-timeline.tsx wholesale and re-apply: delete both inline menu blocks and the local DialogDeleteSession, mount <SessionActionsMenu> with the same callbacks, drop the two pending* store fields, and re-add the share-request effect. If upstream adds a menu item, add it to session-actions-menu.tsx (both variants) rather than back here. Do not "simplify" the component's deferred rename/share into a plain onSelect — focus return is what it exists to survive.
packages/app/src/pages/home.tsx HomeSessionRow gains the same <SessionActionsMenu> (hover/focus/menu-open revealed, vertical dots) and inline rename: while renaming, the row renders a div with InlineEditor instead of the button, because an input inside a button is invalid markup and swallows keystrokes into the open-session handler. NewHome holds one createInlineEditorController() for the list and passes it plus the model down. Its local archiveSession is deleted — createHomeModel() owns archiveSession/renameSession/deleteSession now, so the sidebar and this screen run one implementation (extends the "state both screens need moves to home-model.tsx" split in the persistent-sidebar row above); the call site becomes model.archiveSession. Upstream's SHOW_HOME_SESSION_ARCHIVE block is left exactly as-is — the flag is false, so it renders nothing, and touching it would widen the diff for no gain. data-component="home-session-row" and every other selector are untouched (packages/app/e2e binds to them); the menu adds data-action="session-menu". The Recents rows and "View all" are the same list at two sizes; a menu on one and not the other makes "View all" a dead end. Keep our row structure. On conflict take upstream's row and re-apply the menu, the editing branch, and the model.* action wiring. Never let a data-component/data-action hook drift. If upstream re-enables SHOW_HOME_SESSION_ARCHIVE, drop that block instead — its absolutely-positioned archive button would sit on top of the menu.
packages/app/src/pages/home.tsx HomeProjectRow's menu ends with <ProjectRemovalMenuItems> (fork-only src/sandbox/project-menu.tsx) in place of the <MenuV2.Separator /> + Close item. The component owns the separator too, because a sandbox's own project folder offers neither. On a plain server it renders upstream's Close verbatim; on a sandbox an extra mounted folder gets a destructive Unmount that removes the folder from SandboxConfig.mounts through api.sandboxes.updateMounts and then drops the row with projects.remove(). src/workspace/tabs.tsx exports primaryFolder so the component can ask which folder cannot be unmounted; the decision itself is the pure projectRemoval in src/sandbox/mounts-model.ts. On Work a project row is a folder mounted into a sandbox, so Close — which only hid the row while the sandbox kept the folder — described nothing the user wanted. Removal there means unmounting. Keeping the branch inside a fork-only component leaves this file with one import and one element. Keep the element and the import; take upstream's other menu changes. If upstream adds items after Close, put them before the component — it must stay last, since it owns the trailing separator. Do not pass the row's close handler as the unmount path: close() records the folder as user-closed and src/sandbox/projects.ts honours that, so remounting it would leave the user with no project.
packages/app/src/pages/home.tsx (again) Session lists keyed by id. The "View all" list iterates groups().map((group) => group.id) and, within each group, group.sessions.map((record) => record.session.id), resolving each back through a createMemo + <Show> rather than handing the objects to <For>. groupSessions mints new group objects on every call and createHomeModel's records memo mints a new {session, project, projectName} wrapper per session on every recompute — the projects memo alone returns a fresh array on a pointerdown — and <For> keys on reference, so the rows were torn down and rebuilt on recomputes that changed nothing visible. When that landed between a pointerdown and its mouseup the browser had no surviving common ancestor to fire click on, so a row's ⋮ menu ignored its first click; the same rebuild could close an open menu or drop an in-progress rename. data-component="home-session-row", data-action="session-menu" and the group header refs / opacity keys are unchanged. The fork-only packages/app/src/components/app-sidebar.tsx carries the same shape for the Recents list. A menu that swallows the first click reads as broken, and the cause is structural rather than incidental: any refresh of the session index rebuilt every row. Keep the id keying. On conflict take upstream's list body and re-apply both <For> wrappers. Do not "simplify" back to <For each={groups()}> / <For each={group.sessions}> — that is the bug. If upstream ever makes groups or records referentially stable this can go, but prove it with packages/app/e2e/regression/home-session-menu-first-click.spec.ts before removing it.
Pinned sessions: packages/app/src/pages/home.tsx, packages/app/src/pages/session/timeline/message-timeline.tsx, packages/app/src/pages/layout-new.tsx, packages/app/src/i18n/{en,es}.ts Pin a session to a sidebar section of its own. The fork-only src/context/pinned-sessions.tsx holds the pinned ids — one Persist.global("sidebar.pinned") record keyed by ServerConnection.Key, newest pin first — and layout-new.tsx mounts its provider inside AppSidebarProvider; that is the nearest point covering all three surfaces, since NewAppLayout is the router root. It has to be context rather than state in createHomeModel(): the sidebar and the home screen each hold their own model, so two persisted stores over one key would write the same storage without sharing memory and a pin would not cross between them until a reload — the same reason the project selection lives in useLayout(). home-model.tsx (fork-only) derives pinnedRecords() / recentRecords() from it and deleteSession drops the pins of the whole deleted subtree. In home.tsx, groupSessions takes the pinned records and emits a leading pinned group, and pinned sessions are filtered out of the today/yesterday/older groups so they appear once; HomeSessionRow passes pinned/onPin into <SessionActionsMenu>. message-timeline.tsx passes the same pair, resolving the server the pin belongs to as params.serverKey ? requireServerKey(params.serverKey) : useServer().key — it must match what the sidebar's selection resolves to or the header would toggle a pin the sidebar cannot see. New i18n keys common.pin, common.unpin, sidebar.pinned, home.sessions.group.pinned in en + es. Every data-component/data-action hook is untouched; the new ones are data-section="pinned" and data-slot="sidebar-pinned-list". The Pinned list is keyed by session id for the same reason the row above keys the View all lists — it renders the same rebuilt record wrappers. A purely chronological Recents buries the handful of sessions someone actually returns to, and "View all" is the only way back. Pinning is the smallest fix, and it has to be offered wherever a session's menu is or it is undiscoverable. Keep our structure. On conflict take upstream's home.tsx / message-timeline.tsx wholesale and re-apply: the pinned group id and its filter in groupSessions, the pinnedRecords argument at the groups memo, and the two pinned/onPin props. If upstream adds its own grouping ahead of Today, fold the pinned group in above it rather than replacing it. Never resolve the header's server key differently from the sidebar's — mismatched keys silently split one user's pins in two.
packages/app/e2e/regression/remote-session-settings.spec.ts Both tests pick the settings-shortcut modifier by asking the page — `/(Mac iPod iPhone
packages/desktop/icons/{dev,beta,prod}/ (all PNGs, icon.icns, icon.ico, dock.png), packages/desktop/icons/README.md Regenerated from the fork-only packages/desktop/icons/icon.svg by the fork-only packages/desktop/scripts/generate-icons.ts: the brand-red #ee0033 CoDev "C" — the --mini splash badge glyph, at the VS Code extension icon's proportions (unit = 120/1024, mark 480x600 centered) — on a white tile, replacing upstream's black/blueprint "o" mark. All three channels are byte-identical for now (chosen deliberately over upstream's per-channel treatments); the generator still writes them separately so a channel can diverge later. icon.icns members and dock.png use the macOS Big Sur grid (824/1024 inset + drop shadow); Windows, Linux and Store assets stay full-bleed. README rewritten from the Tauri-CLI + Image2Icon recipe to the generator. The stale Tauri-era android/ and ios/ subfolders are left untouched — nothing in the Electron build reads them. Closes the "desktop app icons need design assets" rebrand gap: the Dock/taskbar icon was still upstream's mark. Keep our assets and README (binary: take ours wholesale). Regenerate via the script rather than hand-editing; if upstream adds an icon size, add it to FLAT_PNGS/ICONSET and rerun.
packages/tui/src/theme/assets/codev.json (new file), packages/tui/src/theme/index.ts, packages/tui/src/context/theme.tsx Added the codev theme — upstream's opencode.json palette with darkSecondary → #0891b2 (brand teal) — registered it in the theme index, and made it the default/fallback theme ("opencode" → "codev" in the store default, kv fallback, and load-failure fallbacks). theme/assets/opencode.json itself matches upstream again. Brand accent lives in a fork-owned theme instead of edits to upstream's palette; the default theme carries the brand. Keep our theme file, its index registration, and the fallback strings; when upstream changes the opencode palette, mirror it into codev.json (keeping our darkSecondary).
packages/opencode/src/cli/ui.ts wordmark/logo() render the lowercase codev wordmark via ansiWordmark (bold, no shadow, "dev" in brand red on TTY) Help banner matches the TUI home banner. Keep our wordmark + logo(); take upstream's surrounding changes.
packages/tui/src/util/presentation.ts sessionEpilogue returns only the one-line Continue codev -s <id> hint — dropped the ansiWordmark banner and the Session <title> line (and the now-unused title input) The exit transcript (see the exit-transcript row) already prints the conversation on quit; the wordmark + title banner was redundant noise above the resume hint. Keep the single Continue line; if upstream re-adds a banner/title here, keep it removed.
packages/tui/src/feature-plugins/sidebar/footer.tsx Removed the footer's brand + version line entirely (upstream's two-tone "Open"+"Code" spans, status dot, and app.version); the cwd:branch path is now the last footer item The fork's sidebar doesn't advertise the product name/version; the path takes that bottom slot. (Supersedes the earlier single-"CoDev"-span rebrand of this line.) Keep the line deleted; take upstream's surrounding changes.
packages/tui/src/feature-plugins/sidebar/footer.tsx Added the editor-context label above the cwd:branch line: useEditorLabel() (fork-only src/context/editor-label.tsx) supplies the effective selection, an editorFile memo formats it via editorContextFileLabel(...) at a fixed width of 36 (the sidebar's 42 columns less 2 of padding a side and 2 for the bullet), and <EditorContextLabel> renders it — both from the fork-only ./editor-context-label.tsx. The open file + selected range belong with the sidebar's other ambient session state rather than in the prompt footer's crowded keybind-hint row; they sit next to the cwd for a single "where am I working" group. Moved out of the prompt footer, which no longer shows it at all. Keep the useEditorLabel import, the editorFile memo, and the <EditorContextLabel> element as the box's first child; take upstream's changes to the path display below it.
packages/tui/src/feature-plugins/sidebar/lsp.tsx Whole LSP section wrapped in <Show when={!off()}> so it renders nothing when config.lsp is falsy; the "LSPs are disabled" placeholder text removed (empty state is only "LSPs will activate as files are read") A disabled feature shouldn't occupy sidebar space with a permanent placeholder. Keep our Show wrapper + removed placeholder; take upstream's surrounding changes.
packages/opencode/src/cli/cmd/*.ts (tui, run, serve, attach, pr, upgrade, uninstall, web) Help describe strings "opencode" → "codev" --help is the first thing users read. Keep our strings.
packages/tui/src/app.tsx Terminal title "OpenCode"/"OC |" → "CoDev Code"/"CC |"; the configure-gate effect is a stateless forced-setup invariant — while sync.status === "complete" && !useConnected() && dialog.stack.length === 0 it (re)opens the fork's DialogConfigure (component/dialog-configure.tsx, new file), so escaping any gate-launched flow or a credential-only save bounces back to the gate instead of an unconfigured chat (gates on !useConnected(), not provider.length === 0 — hosted Zen free models always register); added the codev.configure command (/configure, opening <DialogConfigure dismissable={connected()} /> — dismissable on a configured install, the gate otherwise) and a CODEV_CONFIGURE=1 mount hook for codev configure; home screen skipped — plain startup seeds the dummy session route and auto-opens a new empty session (reusing a leftover default-titled empty one via openNewSession/findEmptySession), session.new and the current-session session.deleted handler open a new session instead of navigating home, and the -c resolver prefers non-default-titled sessions so it lands on the last real conversation; added the app.exit.confirm command + EXIT_CONFIRM_WINDOW const (double-press Ctrl+C to quit: first press shows a toast, second within 1.5 s exits) and gathered its app_exit_confirm binding alongside app_exit; an onMount "Usage reporting is paused" warning toast next to the raster-font one, shown when @codev/upload/nudge's shouldNudgeSignIn() says the launch-time stats push stalled for lack of a CoDev SSO session (SSO-origin keys only; deliberately every launch until the sign-in happens — see the stats-push wiring row); a one-shot createEffect after sync.status === "complete" runs @codev/gateway's ensureGatewayKey() (the startup gateway-key pre-flight shared with the desktop controller): a refreshed result merges the re-minted key into the auth registry for every id it names via sdk.client.auth.set, then instance.dispose() + sync.bootstrap(); a needs-login with reason rejected opens <DialogConfigure dismissable /> when no dialog is up (no-key is ignored — a non-gateway setup, or the gate's own case) Tab title branding; first-run setup is a forced gate the user can't escape into an unconfigured chat (the invariant reopens it); codev starts directly in the session layout instead of the home screen; a stray Ctrl+C no longer quits on the first press. Keep our strings, the invariant gate effect, dialog swap + command block, route seed, openNewSession/findEmptySession + call sites, the -c default-title filter, and the app.exit.confirm command + binding; take upstream's surrounding changes.
packages/tui/bunfig.toml [test] preload gains the fork-only ./test/preload.ts, which sets CODEV_TEST_HOME to a fresh temp dir (unless already set) before any test runs The app mount runs the gateway key pre-flight (ensureGatewayKey) and the upload sign-in nudge at startup; both read the shared auth file under ~/.codev/~/.codev-hub, and the pre-flight can write a re-minted key back. View tests boot the real app, so without an isolated home a developer's test run would probe the real gateway with their real key. Same convention as core's Global and packages/opencode/test/preload.ts. Keep our preload entry; take upstream's other bunfig changes.
packages/tui/src/routes/session/index.tsx Flow transcript: the scrollbox opens with a home-like banner (Logo + home_bottom tips slot, placement="start") as its first entry for root sessions (subagent sessions keep a plain 1-row pad), its maxHeight is clamped to the content height (a transcriptHeight signal read off the content's yoga node in the renderer root's layout-changed handler, which then re-runs calculateLayout so the clamp lands in the same frame — scrollHeight is a frame stale there, and painting with a stale clamp flickers during streaming) so the Prompt rides directly below the last message, and a trailing flexGrow spacer absorbs the leftover space until content fills the screen (then the prompt is effectively bottom-anchored and the banner scrolls away with history); the session Prompt gets home's placeholder pool when the session has no messages so the input shows an Ask anything... "<suggestion>" placeholder like home Startup lands in an empty session instead of the home screen; the transcript flows top-down like terminal scrollback — logo top-left, input following the conversation down — instead of a bottom-anchored prompt with a void above it. Keep our banner block inside the scrollbox, the transcriptHeight clamp + layout-changed listener, the trailing spacer, and the placeholders prop; take upstream's surrounding changes.
packages/tui/src/routes/session/index.tsx Sidebar visibility is session-local: removed upstream's persisted kv.signal<"auto" | "hide">("sidebar", "auto") (and the sidebar() === "auto" && wide() branch of sidebarVisible); visibility is driven only by the non-persisted sidebarOpen signal, which the session.sidebar.toggle command flips (supersedes the earlier "auto" → "hide" default change) The right sidebar starts hidden in every session regardless of terminal width or what the user did last session; ctrl+b/<leader>b only affects the current session. A stale sidebar key in existing kv.json files is simply never read. Keep the kv signal removed and sidebarVisible reading only parentID + sidebarOpen; take upstream's surrounding changes.
packages/tui/src/routes/session/index.tsx Hidden-sidebar surfaces: a sidebarHint memo (modified-file count from sync.data.session_diff, only when the sidebar is hidden and the session isn't a subagent; label from sidebarHintLabel in the fork-only routes/session/sidebar-hint.ts) plus a sidebarShortcut (useCommandShortcut("session.sidebar.toggle")), passed as the Prompt's previously unused hint prop — e.g. ctrl+b 5 modified files; and a compact <TodoPanel> (fork-only routes/session/todo-panel.tsx + todo-panel-model.ts) mounted inside <Show when={visible()}> under an extra <Show when={!sidebarVisible()}>, directly above the session_prompt slot — up to 4 checkbox items windowed around the active todo plus a summary line (2 of 6 finished · ctrl+b to show full TODO), shown only while an unfinished todo exists The sidebar starts hidden in this fork (see the row above), so users would otherwise never see the agent's plan or modified files or how to reveal them. The panel shows the active todos inline, so the footer hint only needs to cover modified files. Keep our sidebarShortcut/sidebarHint memos, the sidebar-hint and todo-panel imports, the TodoPanel mount above the session_prompt slot, and the hint={…} prop on <Prompt> (all additive); take upstream's surrounding changes. If upstream starts passing its own hint to this <Prompt>, merge the two under one Show; if upstream adds its own children to the bottom box, keep TodoPanel directly above the prompt slot.
packages/tui/src/routes/session/index.tsx Assistant-message trailer dedup: a suppressed memo in AssistantMessage hides the ▣ Agent · Model prefix on the last message when it matches the currently selected agent (local.agent.current()?.name === message.mode) and model (local.model.current() vs the message's providerID/modelID), since the prompt footer right below already shows that selection; the duration still renders as Baked for <duration> (and interrupted keeps rendering on aborts), and the trailer box is skipped entirely when suppression leaves nothing to show The last message's trailer and the prompt footer showed the same agent + model twice, one line apart. Keep the suppressed memo and the Show gates around the trailer spans; take upstream's surrounding trailer changes (new suffixes should go inside the existing <text> with a · separator like interrupted).
packages/tui/src/routes/session/index.tsx Trailer extracted from AssistantMessage into an exported MessageTrailer component (it owns the model/final/duration/suppressed/aborted memos, which moved with it) and the last assistant message's copy is hoisted out of the transcript: AssistantMessage renders it inline only when={!props.last}, and Session renders <MessageTrailer message={lastAssistant()} last /> in the pinned block between the todo panel and the session_prompt slot. That block is a <box flexShrink={0} paddingTop={1} gap={1}> wrapping the todo panel, the trailer and the prompt slot, so a blank row appears only between the parts that actually render and no part carries padding for a neighbour that may be absent; the pinned trailer therefore keeps only paddingLeft={3} and the inline one keeps its <text marginTop={1}>. Baked for <duration> is footer-grade status, not transcript content: pinned below the todo panel it stays put instead of scrolling away with history, and the two status blocks above the prompt read as one group. Keep MessageTrailer and both call sites; take upstream's surrounding changes. If upstream restructures the trailer, re-apply the extraction and keep the last message's copy out of the <For> over messages.
packages/plugin/src/tui.ts home_bottom slot props widened from {} to { placement?: "center" | "start" } The empty-session banner asks home_bottom contributors to left-align; optional prop keeps the home route and third-party plugins (no prop → centered) unchanged. Keep our placement prop; take upstream's other slot-map changes.
packages/tui/src/feature-plugins/home/tips.tsx home_bottom slot fn reads the new placement prop; with "start" the tips box left-aligns (flex-start) and drops its paddingTop 3 The top-left session banner needs the tip flush under the logo; the centered home route keeps upstream spacing. Keep our placement threading + conditional alignItems/paddingTop; take upstream's surrounding changes.
packages/tui/src/feature-plugins/system/which-key.tsx HomeHint reads the new placement prop from the home_bottom slot; with "start" it left-aligns Same top-left banner alignment as the tips row above. Keep our placement threading + conditional alignItems; take upstream's surrounding changes.
packages/tui/src/routes/home.tsx placeholder suggestion pool exported (was module-private) The empty-session view in routes/session/index.tsx feeds the same pool to its prompt, so upstream edits to the suggestions flow through. Keep the export keyword; take upstream's changes to the list.
packages/tui/src/routes/session/permission.tsx, packages/opencode/src/cli/cmd/run/permission.shared.ts, packages/opencode/test/cli/run/permission.shared.test.ts, packages/tui/src/component/error-component.tsx, packages/opencode/src/cli/cmd/run/splash.ts Visible sentences/badges "OpenCode"/"opencode" → "CoDev Code"/"codev-code" (incl. the permission always-allow copy and its test expectations); permission.tsx also registers a local app.exit.confirm command mirroring its app.exit reject and gathers the confirm binding (double-press Ctrl+C — see the double-Ctrl+C row) User-facing copy; keep Ctrl+C rejecting the permission prompt as before. Keep our strings; keep permission.tsx's app.exit.confirm command + binding.
packages/opencode/src/cli/cmd/run/splash.ts The --mini entry and exit splashes badge with badge (the "c" of the codev wordmark) instead of go.right.slice(1) (the "o" of the go mark) — the import and both const mark = … lines; header comment "full opencode entry logo and a compact [O] exit badge" → "full codev entry logo and a compact [C] exit badge". The badge glyphs draw with input.theme.mark (brand red) via the local mark_fg binding, not the muted left/leftShadow that upstream uses for the whole banner, and with no shadow — draw's shadow param is now optional and the badge calls omit it, so the flat brand-colored outline has no interior fill. The splash badge is the brand mark users see entering and leaving codev --mini; an "o" is upstream's, and the muted grey read as chrome rather than branding. go itself is untouched — packages/tui/src/component/bg-pulse-render.ts still draws the full "go" wordmark from it. Keep the badge import, both mark assignments, the mark_fg badge fg, and the optional-shadow draw signature; take upstream's surrounding splash changes. If upstream restructures the badge rendering, feed it badge in the brand mark color rather than reverting to go/left.
packages/opencode/src/cli/cmd/run/theme.ts, packages/opencode/test/cli/run/theme.test.ts RunSplashTheme gains a mark field (brand badge color for the mini splash — no shadow color, the badge is a flat outline). splashTheme sets mark to the BRAND_MARK constant (rgba(brand.red) — #ee0033, imported from @/cli/logo) as truecolor, not quantized, in both light and dark; RUN_THEME_FALLBACK.splash seeds mark from the same constant. theme.test.ts asserts mark is exactly [238,0,51]. Deliberately NOT the run theme's own secondary (that is derived from the terminal palette — magenta — and quantizing it gives a dull, off-brand cell). The badge renders the same "C" glyph as the landing page's compact mark (codev-landing-page app/icon.svg), which is brand red in both modes — so the mini splash, the favicon, and the wordmark's red "dev" all agree. The rest of the splash stays palette-matched; the brand mark is a deliberate truecolor exception. Mode-independent, so splashTheme keeps upstream's (theme, indexed) signature. Keep the RunSplashTheme.mark field, the BRAND_MARK constant + brand import, the fallback seed, and the test assertion; take upstream's surrounding theme changes.
packages/tui/src/config/keybind.ts, packages/tui/src/routes/session/question.tsx, packages/tui/src/ui/toast.tsx, packages/tui/test/app-lifecycle.test.tsx, packages/web/src/content/docs/keybinds.mdx Double-press Ctrl+C to quit. keybind.ts: app_exit ctrl+c,ctrl+d,<leader>q → ctrl+d,<leader>q, added app_exit_confirm: "ctrl+c" definition + its app.exit.confirm CommandMap entry. question.tsx: a local app.exit.confirm command mirroring its app.exit reject, with the confirm binding gathered in both option branches (Ctrl+C still rejects the question — single press). toast.tsx: ToastOptions.variant union gains "secondary" (resolves to theme.secondary, the banner brand color, for the confirm toast). app-lifecycle.test.tsx: added the "app.exit.confirm requires two presses to exit" test. See also the app.tsx, dialog.tsx, and permission.tsx rows for their app.exit.confirm handlers. A stray Ctrl+C on an empty/unfocused prompt no longer quits instantly: the first press shows a "Press Ctrl+C again to exit" toast (1.5 s window), the second within that window quits. Ctrl+D, <leader>q, and the /exit slash commands stay immediate. Keep our binding split, the app_exit_confirm/app.exit.confirm definition + mapping, the local confirm commands + gathered bindings, the toast "secondary" variant, and the test; take upstream's surrounding changes. If upstream re-adds ctrl+c to app_exit, keep it removed.
packages/tui/src/config/keybind.ts, packages/web/src/content/docs/keybinds.mdx, packages/opencode/test/cli/run/footer.view.test.tsx sidebar_toggle default "<leader>b" → "ctrl+b,<leader>b" (ctrl+b first so single-binding displays — the prompt-footer sidebar hint, the sidebar tip — resolve to it); ctrl+b's former jobs move to ctrl+o: input_move_left "left,ctrl+b" → "left,ctrl+o", session_background "ctrl+b" → "ctrl+o"; footer.view.test.tsx asserts the run-CLI footer background hint as "ctrl+o background" (was "ctrl+b background") since it resolves the real session_background default Single-chord sidebar toggle on ctrl+b (tmux/VS Code sidebar muscle memory); its displaced bindings land on ctrl+o, which was unbound in every scope and free of terminal-encoding collisions (unlike ctrl+h/ctrl+i/ctrl+m) and flow control (ctrl+s/ctrl+q). Keep our defaults on all three keybinds and the footer hint assertion; take upstream's surrounding changes. If upstream binds ctrl+o to something else, re-audit and pick a new free key.
packages/tui/src/config/keybind.ts, packages/tui/src/component/prompt/index.tsx, packages/tui/src/app.tsx, packages/web/src/content/docs/keybinds.mdx Single-press Ctrl+C interrupt. keybind.ts: added session_interrupt_now: "ctrl+c" + its session.interrupt.now CommandMap entry (session_interrupt keeps escape; its description gains "(press twice)"). prompt/index.tsx: added the session.interrupt.now command — same guards as upstream's session.interrupt but it aborts on the first press, and its enabled requires a busy session AND an empty prompt so ctrl+c falls through to prompt.clear (draft present) or app.exit.confirm (idle) instead of racing them on layer order; gathered the command in the prompt.palette binding list; the busy footer hint reads interruptShortcut() (resolves session.interrupt, mapping escape → esc and falling back to the session.interrupt.now label when escape is unbound) instead of the hardcoded esc. app.tsx: split app_exit_confirm out of the shared exit useBindings into its own layer whose enabled also requires sync.data.session_status[route.data.sessionID] to be idle, so ctrl+c can't arm the quit confirm mid-turn (app_exit keeps the shared predicate). Covered by the fork-only packages/tui/test/interrupt.view.test.tsx. See also the double-Ctrl+C row above, whose scope this narrows. Ctrl+C is what many users reach for to stop a running agent, so it interrupts too; escape stays the advertised one in the busy footer and the home tip. Gating on enabled rather than binding order keeps the ctrl+c precedence (clear draft → interrupt → exit confirm) deterministic, since same-priority layers resolve by registration order and useBindings re-registers on every reactive change. Keep the session_interrupt_now definition + mapping, the session.interrupt.now command and its enabled predicate, the footer shortcut memos, the split exit layer, and the test; take upstream's surrounding changes. If upstream binds ctrl+c to something else in the prompt, re-audit the precedence chain end to end.
packages/tui/src/app.tsx, packages/tui/src/context/epilogue.tsx, packages/tui/src/routes/session/index.tsx, packages/tui/test/app-lifecycle.test.tsx Exit transcript. epilogue.tsx: the epilogue setter accepts string | (() => string). app.tsx: the renderer.once("destroy") listener materializes a thunk epilogue before resolving shutdown (it runs before @opentui/solid's own destroy listener, so the reactive tree and sync store are still alive), and the EpilogueProvider setter ignores writes once renderer.isDestroyed — fixing a pre-existing race where the session route's onCleanup(() => setEpilogue()) ran during Solid dispose and clobbered the epilogue before run() printed it. routes/session/index.tsx: the epilogue effect sets a thunk that prepends a compact transcript of the session (fork-only util/exit-transcript.ts + test/util/exit-transcript.test.ts, not tracked here) to sessionEpilogue, painted with the active theme's colors (truecolor ANSI; error-status tools get the error color, and assistant text gets a line-based markdown approximation of the session's <markdown> renderable — headings/bold/italic/inline code/fences/lists/links styled with the theme's markdown* colors), reading messages()/parts/theme only inside the thunk so message updates don't rebuild it. app-lifecycle.test.tsx: the epilogue test seeds a session with messages and asserts the transcript prints; its session id is ses_demo because dummy collides with the fork's placeholder route id and the Session route never mounts for it (this collision is why the test failed before), with session-scoped endpoints and a paid model stubbed and a frame-poll wait for the session view before dispatching app.exit. Quitting the TUI restored the alternate screen and threw the conversation away; now the transcript (user > prompts, assistant text, dim ⏺ Tool(arg) one-liners — no thinking, no tool output) survives in terminal scrollback like Claude Code, followed by the usual epilogue. Keep the thunk union, the destroy-listener materialization, the isDestroyed setter guard, the thunk-producing effect, and the test's ses_demo id + stubs; take upstream's surrounding changes.
packages/opencode/src/installation/index.ts Updater detects/queries/installs codev-code (npm-family); GitHub fallback → quickbeard/codev-code. method() drops upstream's ~/.local/bin → curl check, keeping only ~/.opencode/bin. The curl upgrade branch refuses instead of running upstream's install script: upgradeCurl + upgradeScriptShell + the Stream import are removed, and case "curl" returns UpgradeFailedError pointing at npm i -g codev-code codev-code upgrade and the update notification target OUR npm package, never stock opencode. brew/choco/scoop probes never match (no such packages). A curl-detected binary (in ~/.opencode/bin) is a stock opencode install; piping upstream's opencode.ai/install script would silently replace the fork with stock opencode. ~/.local/bin is dropped because it's also a common npm/bun global-bin prefix, so a legitimate package-manager install there was misdetected as curl (now falls through to the manager probes). Keep our package names, the ~/.local/bin removal, and the curl refusal; keep upgradeCurl/upgradeScriptShell/Stream deleted (take upstream's changes elsewhere).
packages/opencode/src/cli/cmd/uninstall.ts npm-family uninstall commands (display + exec maps) opencode-ai → codev-code codev uninstall must remove the fork's actual npm package; the old name was a silent no-op for every npm-installed fork user. NOT renamed: brew/choco/scoop package names (opencode — the fork publishes no such formula/package, and method() never resolves to those for a fork install) and the curl .opencode/bin + # opencode shell markers (they match upstream install-script artifacts, not the fork's). Keep our codev-code names; take upstream's surrounding changes.
packages/opencode/src/config/config.ts share defaults to "disabled" when unset Sharing publishes sessions to upstream's share service; internal users must opt in via config. Keep our default block.
packages/opencode/src/config/config.ts ensureModuleType(dir) runs beside ensureGitignore(dir) for every config directory: seeds "type": "module" into the dir's package.json (creating the file when missing, merging when present, leaving any explicit user-set type and unparseable files alone; PermissionDenied swallowed like the gitignore write). Covered by three cases in test/config/config.test.ts. Node loads the dir's TS tools/plugins as ESM, but the package.json the dependency install writes carries no type, so every tool load printed a MODULE_TYPELESS_PACKAGE_JSON warning (plus a re-parse overhead) on the server's stderr. Seeding before the install works because Arborist preserves fields it does not manage. Keep the helper and its call next to ensureGitignore; take upstream's surrounding changes. If upstream adds its own fix, prefer theirs and drop ours.
packages/opencode/src/config/config.ts Config layer init calls ConfigGatewayKeyMigration.migrateInlineGatewayKeys(authSvc, fs) (fork-only src/config/gateway-key-migration.ts, covered by fork-only test/config/gateway-key-migration.test.ts) before the first global-config load, wrapped in Effect.catch → log-warning so a failure never blocks startup One-time migration for configs written before the keyless gateway block: older writers (the codev hub, this fork's configure flows) inlined the gateway API key as provider.<id>.options.apiKey in the global codev.json(c); the credential now lives in opencode's auth.json (registry merge by provider id). Beyond hygiene, an inline key outranks the auth-store key at SDK construction, so a stale leftover would pin requests to a rotated-out credential; an old hub that re-inlines the key on refresh is simply re-migrated next launch. Must run before loadGlobal first caches the file. Keep the import + call (and its placement before the cached global load); take upstream's surrounding layer changes.
Config-file rename: packages/opencode/src/config/{config,managed,tui-migrate}.ts, packages/core/src/config.ts, packages/opencode/src/plugin/install.ts, packages/opencode/src/cli/cmd/{mcp,plug,providers,error}.ts, user-facing strings (packages/{tui,app} dialogs/tips/i18n, skill descriptions in packages/core/src/plugin/skill.ts + packages/opencode/src/skill/index.ts), bundled prompts (customize-opencode.md, initialize.txt), tests seeding config fixtures, docs (packages/web/src/content/docs), stragglers (.gitignore root ignore entry, packages/tui/src/component/dialog-provider.tsx credential-dialog strings, packages/tui/src/util/error.ts model-not-found hint, packages/opencode/script/run-workspace-server header comment, specs/v2/config.md discovery prose) Config file opencode.json(c) → codev.json(c) everywhere (global, project walk-up, config dirs, managed); global config.json support and the legacy TOML config migration removed; managed config dir /Library/Application Support/opencode→…/codev, /etc/opencode→/etc/codev, %ProgramData%\opencode→…\codev The fork's config surface uses the fork's name; hard rename, no legacy fallback. NOT renamed: the $schema URL https://opencode.ai/config.json, @opencode-ai/* packages, theme asset files named opencode.json, brew/scoop manifest URLs. Keep our file names; take upstream's surrounding logic. Rename any new upstream opencode.json/global-config.json reference to codev.json (watch the not-renamed list).
.opencode/ (repo root, whole directory) Renamed to .codev/; inner opencode.jsonc → codev.jsonc; internal .opencode/ path mentions in glossary/README.md, skills/effect/SKILL.md, command/translate.md updated; the opencode-local reference → codev-local pointing at ~/.local/share/codev This is the repo's own dogfooding config dir; after the config-dir rename row below, the agent only discovers .codev/. Keep our directory; re-apply upstream's .opencode/** changes into .codev/ (sync surfaces them as modify/delete or add conflicts).
Config-dir rename: packages/opencode/src/config/{paths,config,tui}.ts, packages/core/src/config.ts, packages/opencode/src/plugin/install.ts, packages/opencode/src/plugin/tui/runtime.ts, packages/tui/src/context/theme.tsx, packages/opencode/src/cli/cmd/{agent,mcp}.ts, packages/opencode/src/session/session.ts, plan permission patterns (packages/opencode/src/agent/agent.ts, packages/core/src/plugin/agent.ts), TUI tips, skill descriptions, customize-opencode.md, specs/tui-plugins.md, tests creating .opencode fixtures, docs (en + es) Config directory .opencode → .codev (project walk-up, ~/.codev, plans dir, theme/plugin discovery) The fork's config surface uses the fork's name; hard rename, no legacy fallback. NOT renamed: ~/.opencode/bin install paths (binary location — see installation/uninstall rows), the ai.opencode.managed plist domain, identifiers like provider id opencode and theme asset keys. Keep our dir name; take upstream's surrounding logic. Rename any new upstream .opencode dir reference to .codev (leave ~/.opencode/bin).
packages/tui/src/component/dialog-provider.tsx, packages/tui/src/component/dialog-model.tsx, packages/opencode/src/cli/cmd/providers.ts Filter opencode/opencode-go out of provider pickers and the /model dialog (model list + Favorites/Recent; the model dialog's opencode-first sort became a plain name sort) Upstream's hosted offerings (Zen/Go) are detours for gateway users. Keep our filters.
packages/tui/src/component/dialog-provider.tsx The "Other" custom-provider option routes into the fork-only runCustomProviderFlow (component/dialog-custom-provider.tsx, new file) — id → API key → optional base URL → model discovery via /v1/models → provider.<id> block PATCHed into global codev.json (key stays in auth.json) — instead of upstream's credential-only ApiMethod; the local promptCustomProviderID helper moved into that flow (upstream's "only stores a credential" description no longer holds). ApiMethod's custom prop is now unreferenced but left in place. An empty base URL keeps upstream's credential-only behavior; with one, the provider is usable without hand-editing codev.json. The configure gate's "Custom provider" option shares the same flow. Keep our onSelect + import and the helper deletion; take upstream's changes to ApiMethod/other methods (the flow only wraps the entry point).
packages/tui/src/component/prompt/index.tsx Gated the prompt footer's model/provider indicator on useConnected() (the shared "a real provider is configured" predicate) Don't advertise the free hosted Zen default (e.g. "Big Pickle · OpenCode Zen") in the footer while nothing is configured — same rationale and predicate as app.tsx's startup gate and the provider/model filters. A configured gateway/real provider, or a deliberately-authenticated opencode/opencode-go, still shows its model. Keep the extra Show condition; take upstream's surrounding changes.
packages/tui/src/component/use-connected.tsx, packages/tui/src/context/local.tsx use-connected.tsx: the provider predicate is extracted into an exported isConnectedProvider; useConnected now delegates to it (same logic). local.tsx: fallbackModel's last resort is sync.data.provider.find(isConnectedProvider) ?? sync.data.provider[0] instead of a blind sync.data.provider[0] (covered by fork-only test/component/use-connected.test.ts, not tracked here). The hosted Zen provider self-registers (free models need no auth) and is inserted ahead of config-only providers, so with no config model pin and empty recents (a fresh codevhub install — the pin was dropped in PR #142 / hub PR #221) the TUI defaulted to "Big Pickle · OpenCode Zen" and would send messages to upstream's hosted gateway instead of the configured one. Same predicate as app.tsx's startup gate and the footer indicator; when no provider qualifies the old first-provider behavior is kept. Keep the predicate export and the find fallback; take upstream's surrounding changes to either file. If upstream reworks fallbackModel, re-apply the connected-first selection to its last-resort branch.
packages/tui/src/component/prompt/index.tsx The busy-state footer arm (status().type !== "idle") also renders props.hint after the esc interrupt text, gated on status().type !== "retry" The hidden-sidebar hint (see the routes/session/index.tsx hint row) only rendered in the footer Switch's idle fallback arm, so it vanished for the whole duration of a running turn — exactly when todos are being created. Retry keeps its space-between error row uncluttered. Keep the <Show when={status().type !== "retry" && showBusyHint()}>{props.hint}</Show> line in the busy Match arm (additive); take upstream's surrounding changes.
packages/tui/src/component/prompt/index.tsx Width-tiered busy footer: module-level BUSY_FOOTER_PALETTE_MIN_WIDTH (108) and BUSY_FOOTER_HINT_MIN_WIDTH (88) drive a showPaletteHint memo (status().type === "idle" || width >= 108) wrapping the <shortcut> commands text and a showBusyHint memo (width >= 88) added to the busy arm's props.hint gate The busy row carries spinner + interrupt hint + sidebar hint + context/cost; as the terminal narrows flexbox runs the gaps to zero (cramped but intact) and below ~85 columns the row wraps to two lines and clips the cost. Shedding by value keeps it on one line down to 70: the static palette hint goes first, then the modified-file count; the spinner and interrupt hint never go. Keep both constants, both memos and the two Show gates; take upstream's surrounding changes. Re-measure the thresholds if segments are added to the row.
packages/tui/src/component/prompt/index.tsx The footer's right slot renders key hints and usage independently instead of upstream's either/or Switch (usage() else hints): module-level FOOTER_HINTS_AND_USAGE_MIN_WIDTH (120) drives a usageMeta memo (usage data, shown when width >= 120 or status().type !== "idle") and a showKeyHints memo (!usageMeta() || width >= 120) replacing the inner Switch/Match pair with two sibling Show gates. Covered by test/footer-width.view.test.tsx. Upstream shows the shift+tab modes / tab agents hints only while the session has no assistant output tokens, so one prompt permanently swaps them for the context/cost readout and the shortcuts stop being discoverable. A wide terminal fits both; narrower ones show the hints while idle (when you'd press them) and usage while busy (when it's changing). Keep the constant, both memos and the two Show gates; take upstream's surrounding changes. Re-measure the threshold if segments are added to the row.
packages/tui/src/component/prompt/index.tsx Removed the editor open-file label from the footer's right-side group and lifted the state behind it out of Prompt: the local dismissedEditorSelectionKey signal and the editorContext/editorPath/editorSelectionLabel/editorFileLabel/editorFileLabelDisplay/editorContextLabelState memos are gone, replaced by useEditorLabel() (fork-only src/context/editor-label.tsx) — selection() feeds the prompt.editor_context.clear enabled predicate and the submit-time editor part, state() replaces editor.labelState() there, and dismiss() replaces the local dismissEditorContext. The pure helpers hasEditorRangeSelection/getEditorRangeLabel moved to that context module and are imported back for formatEditorContext. The label now lives in the sidebar footer (see its row above). The dismissal state had to move with it: the sidebar must label the effective selection — the one this file actually attaches on submit — or it would advertise context the prompt has dismissed and won't send. Keep the deletions, the useEditorLabel import + its three call sites, and the helper import from ../../context/editor-label; take upstream's surrounding changes. If upstream reworks the editor-context label, apply the change to editorContextFileLabel in the fork-only sidebar module rather than restoring the footer render.
packages/tui/src/routes/session/index.tsx, packages/tui/src/component/prompt/index.tsx Left accent border removed entirely: the user-message block's border={["left"]}/borderColor (agent accent)/customBorderChars props and the prompt input box's two bordered boxes (main box with its bottomLeft: "╹" char, plus the height-1 ╹ terminator row) are replaced with paddingLeft={1}; the now-unused borderHighlight memo and the tint/SplitBorder imports are removed from the prompt (the highlight/agentMetaAlpha memos stay — they still color the agent name label); the fork's in-scrollbox banner box gains paddingLeft={1} so the banner/tips left edge lines up with the prompt/message blocks' background edge (the removed border glyph used to occupy that aligned column); BlockTool (expanded tool-output panels) likewise drops its border={["left"]}/customBorderChars/borderColor (the background-colored ┃ glyph over the panel background rendered as a stray vertical line on the panel's left edge) and bumps paddingLeft 2 → 3 to keep the content column No vertical accent line next to sent messages, the prompt, or tool-output panels — removed rather than recolored to the background because background-colored glyphs render oddly on some terminals (e.g. PowerShell). paddingLeft={1} keeps the same 1-column offset, so text alignment with other transcript blocks is unchanged. Keep our paddingLeft={1} boxes and the borderHighlight/tint/SplitBorder removals; take upstream's surrounding changes. If upstream adds new accent-colored left borders to these blocks, replace them with paddingLeft={1} the same way.
packages/tui/src/component/spinner.tsx SPINNER_FRAMES swapped from cli-spinners "dots" (10 braille frames) to cli-spinners "sand" (35 braille frames, falling-sand animation); interval stays 80 ms Fork visual identity for the shared Spinner component (in-progress tool/thinking rows, startup loading, session-list dialogs). Keep our frames array; take upstream's surrounding changes.
packages/tui/src/component/prompt/index.tsx Busy footer spinner is cli-spinners "aesthetic" instead of the Knight Rider block scanner: module-level FOOTER_FRAMES (▰▱ ramp, 7 chars, 8 frames), spinnerDef keeps the agent-color resolution but returns those frames plus a per-char color fn (▰ → agent color, ▱ → deriveInactiveColor(color, 0.3)) instead of createFrames/createColors from ui/spinner, and the <spinner> interval is 120 ms (was 40; cli-spinners' stock 80 felt too fast). ui/spinner.ts itself is untouched; only its deriveInactiveColor import remains. Fork visual identity for the busy indicator next to esc interrupt; the agent accent color still drives it. Keep FOOTER_FRAMES, our spinnerDef body, the deriveInactiveColor import, and the 120 ms interval; take upstream's surrounding changes. If upstream reworks spinnerDef, re-apply the aesthetic frames + per-char color fn on top.
packages/tui/src/component/prompt/index.tsx, packages/tui/src/routes/session/index.tsx Compact prompt/footer spacing: the input box's interior paddingTop={1} is replaced by a height-1 half-block top-edge row above the box (border={["top"]}, horizontal: "▄" when backgroundElement is opaque, " " otherwise — mirror of the existing "▀" bottom-edge row; the agent/model meta row keeps its paddingTop={1}), and the session content container loses its paddingBottom={1} and its gap={1} (the gap fired between every pair of children — transcript↔prompt, and prompt↔the trailing flexGrow spacer, where it left a phantom blank row under the footer even at zero spacer height; the half-block lip is now the only transcript↔prompt separation) so the prompt footer line sits on the terminal's last row The prompt box was a row taller than its content needed, a full blank row sat between the transcript and the box, and the footer floated 2 rows above the bottom edge; the half-block lip keeps a visible box edge above the text at half the height. Keep the top-edge lip row and the padding/gap removals; take upstream's surrounding changes. If upstream restyles the box edges, re-apply the symmetric half-block lips and keep the container un-padded.
packages/tui/src/routes/session/footer.tsx Deleted. The file exported a Footer component (cwd, pending-permission count, LSP/MCP counts, /status) that nothing imports — it is dead upstream too, superseded there and here by the sidebar footer and the home footer. Its contents live in feature-plugins/sidebar/footer.tsx (cwd:branch, editor-context label) and feature-plugins/home/footer.tsx (dir, MCP, /status, version); keeping an unreferenced third copy invites edits to a component that never renders. Take upstream's version only if upstream starts importing it again; otherwise re-delete (git rm) after a sync reintroduces the file.
packages/tui/src/component/prompt/index.tsx The agent/permission-mode/model/provider/variant row moved out of the input box: it was the second child of the backgroundElement box under the <textarea> (with paddingTop={1}), and is now the first child of the prompt's root box, above the ▄ top-edge row, carrying paddingLeft={3} paddingRight={2} so it lands on the same columns as before. The session_prompt_right slot moved with it. The line describes what the next prompt will use, not what is in the box, so it reads as a header for the input rather than a caption under it, and it now groups with the pinned trailer and todo panel as one status block above the box. Net row count is unchanged: the box loses its interior blank row, the pinned block's gap spends one. Keep the row in its new position; take upstream's surrounding changes. If upstream restructures the input box, re-lift the row rather than merging it back inside.
packages/tui/src/routes/session/question.tsx Selection/hover highlight color: a highlight memo (tint(theme.backgroundPanel, theme.text, 0.1)) replaces theme.backgroundElement on the active option row (number + label boxes, both the option list and the "Type your own answer" row) and on the question/Confirm tab hover state backgroundElement sits ~1.04x off backgroundPanel in light themes (#f5f5f5 on #fafafa for codev), so the selected answer had no visible highlight; blending the panel toward the theme's text color keeps a neutral band that is ~1.2x in both modes and works for generated/system themes too. Keep the highlight memo and its four/two call sites; take upstream's surrounding changes. If upstream adds new backgroundElement highlights in this panel, point them at highlight() too.
packages/tui/src/app.tsx Mounted <EditorLabelProvider> (fork-only ./context/editor-label) immediately inside <EditorContextProvider> The effective-editor-selection state is shared by the prompt and the sidebar footer, so it has to be provided above both. Keep the import and the wrapper; take upstream's surrounding provider changes. If upstream moves EditorContextProvider, keep ours directly inside it.
packages/app/index.html <title> → "CoDev" Web UI tab title. Keep our title.
packages/app/src/pages/layout.tsx, packages/app/src/pages/layout-new.tsx Dropped the <TabsInfoPopup /> mount and its import from both layouts (added upstream in v1.17.18; since v1.18.3 upstream itself removed the sibling HelpButton, so nothing is imported from help-button anymore) TabsInfoPopup is a dev-channel-only "Introducing Tabs" promo whose muted autoplay loop <video> mounts on every layout render. The fork's Windows e2e runs on GPU-less GitHub-hosted runners (software GPU — see the runner-swap row), where continuous video decode wedges the GPU command buffer (kTransientFailure), making the whole app-e2e suite flaky and ~3–5× slower. It never ships (release builds use a non-dev CODEV_CHANNEL), so unmounting it costs users nothing. Keep ours: don't re-add the <TabsInfoPopup /> mount or restore it to the import.
packages/app/src/components/help-button.tsx Deleted (as of the v1.18.3 sync). The fork had removed the TabsInfoPopup component and its introducing-tabs.mp4 import, keeping the rebranded HelpButton; upstream v1.18.3 deleted HelpButton itself, leaving only the popup — so nothing remains and the file is gone. The popup is upstream's temporary, OpenCode-branded "Introducing Tabs" promo (upstream's own comment says it's removable after the rollout), unmounted per the row above; removing the definition lets the 13 MB video be deleted (row below). Keep deleted (git rm the upstream side of modify/delete conflicts). If upstream mounts TabsInfoPopup somewhere new, drop that mount per the row above. When upstream deletes the popup itself, this row and the rows around it retire.
packages/app/src/assets/help/ (introducing-tabs.mp4, placeholder.png, home.png, tabs.png — whole directory) Deleted The 13 MB promo video's and the v1.18 home/tabs screenshots' only consumer was the removed TabsInfoPopup (row above) and they bloat every clone; placeholder.png is referenced by nothing. Keep deleted (git rm the upstream side of modify/delete conflicts); delete new upstream assets under this directory whose only consumer is the removed popup.
packages/app/src/desktop-menu.ts, packages/app/src/components/windows-app-menu.tsx App-menu label "OpenCode" → "CoDev"; "OpenCode Documentation" item → "CoDev Documentation" (href opencode.ai/docs stays — real URL); trigger aria-label "OpenCode menu" → "CoDev menu" (×2). Plus a "Check for Updates..." item added to the Help menu, it and a preceding separator both platforms: ["windows"]; desktop-menu.ts gains an exported desktopMenuItemDisabled(item, {updaterStatus, commandDisabled}) — sibling to the existing desktopMenuVisible — which WindowsAppMenu now calls to honour enabled: "updater". Upstream only ever exposes the update check from the macOS app menu (platforms: ["macos"]), and createMenu early-returns on non-darwin while WindowsAppMenu filters on "windows" — so on Windows and Linux there was no menu path to it at all, leaving Settings → General as the only manual entry point. WindowsAppMenu renders for both Windows and Linux (titlebar.tsx gates on windows() || linux()) but filters with the "windows" key, so platforms: ["windows"] reaches both without widening DesktopMenuPlatform. The separator carries the same restriction or macOS's Help menu ends on a stray divider. enabled: "updater" was silently ignored in this component — only command entries could disable — so a dev build would have shown an item that errors on click; the updater's own disabled status is the renderer-side equivalent of the main process's UPDATER_ENABLED. Keep our labels (they live in the dicts now — see the localized desktop menu row), the Help entry, and entryDisabled; take upstream's surrounding menu changes. Rebrand any new "OpenCode" label/aria-label. If upstream adds its own non-macOS update entry, drop ours for theirs. Covered by packages/app/src/desktop-menu.test.ts ("offers an update check on every platform exactly once"), which will fail on a duplicate rather than let both ship, plus the item disabled state block over desktopMenuItemDisabled. Keep the disabled logic in desktop-menu.ts rather than inlining it back into the component — it is unreachable from a test there, since the app package has no component-render harness.
Localized desktop menu (fork items): packages/app/src/desktop-menu.ts, packages/app/src/desktop-menu.test.ts, packages/app/src/i18n/{en,es}.ts, packages/app/src/i18n/desktop-native.ts, packages/desktop/src/main/menu.ts Since the v1.18.19 sync the menu rides upstream's native-translations pipeline (labelKey: DesktopNativeKey, macOS resolves through nativeT from the renderer-pushed bundle; the fork's earlier main/i18n.ts translator, refreshMenuLanguage, and the store-set hook were deleted as superseded). The fork's residue: one menu item — Log in/Configure (desktop.menu.login, command gateway.apiKey.new) — in the macOS app menu and as a platforms: ["windows"] twin in File; its key added to DESKTOP_NATIVE_ENGLISH; and desktop-menu.test.ts asserts every labelKey resolves in en/es/vi (the vi dict is fork-only). The item is a fork feature (see the gateway key recovery row); the parity test is the tripwire that keeps every menu label translated in all three locales. Keep the item (and its platforms twin — Windows/Linux render no app menu), its registry key, and the three-locale parity test; take upstream's surrounding menu changes. An upstream item arriving with a key missing from a fork locale fails the parity test — add the translation, don't drop the test.
Hardcoded product-string rebrand in TUI + web app: packages/tui/src/app.tsx, packages/tui/src/routes/session/permission.tsx, packages/tui/src/attention.ts (+ packages/opencode/test/cli/cmd/tui/attention.test.ts), packages/app/src/wsl/settings-model.ts (+ .test.ts), packages/ui/src/components/favicon.tsx User-visible "OpenCode" strings outside the i18n dicts → fork brand: update-complete toast "Successfully updated to OpenCode v…" → "CoDev Code"; permission dialog "…until OpenCode is restarted" → "CoDev Code" (the sibling patterns line was already rebranded); OS-notification fallback title opencode → codev and built-in sound-pack display name "OpenCode Default" → "CoDev Code Default" (pack id opencode.default stays — internal identifier; the three default-title expectations in packages/opencode/test/cli/cmd/tui/attention.test.ts follow, while its explicit-title pass-through fixture stays opencode); WSL settings actions "Install/Update OpenCode" → "…CoDev" (+ test expectations/titles; fixture paths /usr/local/bin/opencode stay); PWA apple-mobile-web-app-title "OpenCode" → "CoDev Code" (matches the rebranded site.webmanifest, favicon-assets row) These render in surfaces the fork ships (TUI + embedded web UI); the i18n-dict strings remain a known gap (UI branding bullet below). Keep our strings; take upstream's surrounding changes. Rebrand any new hardcoded user-visible "OpenCode" string in these surfaces; leave identifiers, URLs, and fixture paths.
Universal macOS builds: packages/desktop/electron.vite.config.ts, packages/desktop/electron-builder.config.ts, packages/desktop/package.json Three changes that together make electron-builder --mac --universal (and --x64) produce a working app. (1) The codev:node-pty-narrower plugin and its nodePtyPkg constant are gone; the main build externalises "@lydell/node-pty" itself. (2) @lydell/node-pty moves from devDependencies to dependencies, so the wrapper is actually packed. (3) mac.x64ArchFiles is set to **/app.asar.unpacked/node_modules/**. (1) Upstream rewrote the import to @lydell/node-pty-${process.platform}-${process.arch} — the build host's arch, resolved once, while electron-vite build runs before electron-builder and emits one bundle for whatever is packaged after it. On Apple Silicon that baked darwin-arm64 in, so an --x64 or --universal build shipped an Electron shell that starts and then cannot load its terminal backend. The wrapper's own entry does the same platform/arch lookup at run time, so externalising it picks the right binary on the machine that runs the app. (2) The wrapper was a devDependency and therefore never shipped: the old narrowing worked only because it pointed at the platform packages, which are optionalDependencies. Verified against a packaged build — asar list showed @lydell/node-pty-darwin-* present and @lydell/node-pty/index.js absent — so require("@lydell/node-pty") could not resolve. The bundled server (packages/opencode/dist/node/node.js) already required the bare wrapper, so this was a live bug in packaged builds before the vite change, not one it introduced. (3) All six platform packages ship in both the x64 and arm64 halves, so eight Mach-O files (node-pty's pty.node and its extension-less spawn-helper, @parcel/watcher, msgpackr-extract) are byte-identical across them; @electron/universal reads that as a mistake and refuses to merge. They are prebuilt binaries chosen at run time, not per-arch build output. Keep all three. On conflict take upstream's files and re-apply. Do not reintroduce host-arch narrowing to silence a bundler warning, and do not move @lydell/node-pty back to devDependencies — the two are load-bearing together. Verify a packaged build rather than trusting the build log: grep -o "@lydell/node-pty[a-z0-9-]*" out/main/index.js must print the bare wrapper with no platform suffix, asar list must contain /node_modules/@lydell/node-pty/index.js, and the app must load its pty.node from inside the bundle when run from outside this repo — inside it, Node's module walk-up finds the repo's node_modules and masks a missing wrapper.
Desktop app text + identifier rebrand: packages/desktop/** (README, package.json, src/renderer/index.html, src/renderer/i18n/{en,es}.ts, src/main/{index,windows,server,logging}.ts, src/main/wsl/{sidecar,startup,servers,servers.test}.ts, src/main/install-state.test.ts, scripts/copy-metainfo.ts, electron-builder.config.ts) plus the WSL/deep-link surfaces it shares with packages/app (src/wsl/{types,settings-model,settings-model.test,settings,dialog-add-server}.ts(x), src/i18n/{en,es}.ts, src/pages/layout/{deep-links,helpers.test}.ts, src/app.tsx, src/index.ts) and packages/desktop/src/{preload,renderer} Every user-visible "OpenCode" the desktop app renders → "CoDev" (originally "CoDev Code"; the "Code" suffix was dropped in 2026-08 once the app carried a Work section beside Code): crash dialogs, window <title>, updater/CLI-installed i18n (en+es, with the CLI-command hint 'opencode'→'codev'), the unpackaged app-name fallback "OpenCode Dev", the AppStream metainfo (product name, description, developer → <name>CoDev</name> with the upstream id="ly.anoma" dropped, the three URLs repointed to the fork repo, upstream's lander screenshot removed), the OS deep-link handler display name, README, and package.json (homepage → the fork repo, author → CoDev <minhnh49@viettel.com.vn>; both were briefly deleted outright, which broke deb/rpm packaging — electron-builder derives the Debian Maintainer field from author.email and refuses to build fpm targets without homepage, so the two fields have to carry fork values rather than be absent. AppImage/nsis/dmg do not use fpm and never noticed. Values match the AppStream metainfo scripts/copy-metainfo.ts already emits: <developer><name>CoDev</name> and <url type="homepage">). Codebase identifiers renamed Opencode→Codev/opencode→codev (WslOpencodeCheck, resolveWslOpencode, installWslOpencode, expectOpencodeVersion, releaseOpencodeResolve, checkOpencode/refreshOpencodeCheck*/setOpencodeCheck, opencodeChecks, wslOpencodeAction, …) together with the internal wire values whose producer and consumer both ship here (virtual:opencode-server, the opencode:{node-pty,virtual-server,copy-server} vite plugin names, the wsl-servers-install-opencode IPC channel, the install-opencode job kind, the __OPENCODE__ deep-link bridge global, the opencode:deep-link renderer event, the opencode-onboarding- temp dir, the wsl.onboarding.installOpencode* i18n keys), and the server-log collector path <xdgData>/opencode/log → /codev/log (a real bug fix — the fork CLI writes to <xdgData>/codev/log, packages/core/src/global.ts app = "codev", so debug-log export was silently collecting nothing). Two stale product-named locals renamed to what they hold (opencode→binary in wsl/sidecar.ts, opencode→checked in the servers test). The fork ships the desktop app, so every surface it shows and every identifier a maintainer reads should say codev. Keep our strings + identifiers; take upstream's surrounding changes. PRESERVED deliberately, do not rename: @opencode-ai/* package ids, the opencode data dir in install-state, the basic-auth username "opencode" (must match the server default in packages/server/src/auth.ts), and the ../opencode/dist build paths. The persisted store names were preserved here originally and are now rebranded with a migration — see the persisted-store-name row below.
Persisted store names: packages/app/src/utils/persist.ts, packages/app/src/{entry.tsx,context/language.tsx}, packages/desktop/src/main/{index,store-keys,store,store-cleanup,install-state,windows,updater,migrate}.ts, packages/desktop/src/renderer/{index.tsx,i18n/index.ts}, plus the tests and packages/app/e2e specs that seed them Every persisted store the app names is codev.* instead of opencode.*: the desktop store files (codev.settings, codev.updater, codev.{global,workspace,draft,window}.*.dat under %APPDATA%\ai.codev.desktop and its per-OS equivalents) and the browser build's localStorage keys, which prefix the same store names (codev.global.dat:server). persist.ts's GLOBAL_STORAGE, WINDOW_STORAGE, LOCAL_PREFIX (the eviction filter) and the workspaceStorage/draftStorage name builders carry the new prefix; store-cleanup.ts's storeKind regexes, install-state.ts's settings-file check and windows.ts's windowDataFile follow. Existing state is carried across by two fork-only one-time migrations: packages/desktop/src/main/store-rebrand.ts renames every opencode.* file in userData on startup — called from main/index.ts immediately after app.setPath("userData", …), before the first getStore() and before the onboarding directory scan — and packages/app/src/utils/storage-rebrand.ts does the same for localStorage keys, called from persist.ts module init and from entry.tsx's pre-render read. Both keep an existing codev.* target and leave the leftover alone rather than overwriting live state. The Tauri importer in migrate.ts keeps reading Tauri's opencode.*.dat files (that is what Tauri wrote) but rebrands each target store name through rebrandStoreName. The desktop troubleshooting docs tell users to delete these files by name to reset the app, so they are user-visible, not an internal id — a user following that page should not be reading upstream's product name out of their own app data directory. This reverses the original decision to preserve them, which rested on the orphaned-state cost; the migrations are what make the rename safe. Keep the new names and both migration modules together — the names without the migrations silently reset every existing install. Take upstream's surrounding changes. The startup call site is load-bearing: do not move renameLegacyStoreFiles below initializeOldLayoutEligibility or any other getStore() caller, or the store opens under the new name first, writes a fresh file, and the rename then finds its target occupied and strands the user's real state. Same for the persist.ts module-init call. The migrations can be deleted once no supported upgrade path still has pre-rebrand files — not before. migrate.ts's source filenames stay opencode.*.
packages/app/src/i18n/{en,es}.ts, packages/app/src/pages/layout/deep-links.ts App-self-reference "OpenCode" → "CoDev" across the shared web UI's i18n dicts (28 strings per locale): settings row descriptions, the update toasts, the WSL onboarding step/status strings, the dialog.server.description, error.chain.mcpFailed, sidebar.gettingStarted.line1, the provider-connect "use {{provider}} models in OpenCode" lines, and app.name.desktop. The Zen free-models dialog title (dialog.model.unpaid.freeModels.title, "Free models provided by OpenCode") was neutralized to "Free models included" / "Modelos gratuitos incluidos" rather than rebranded or kept — it read oddly branded either way. i18n keys are untouched (they carry Opencode/lowercase opencode, which never match the exact-case OpenCode in values). The deep-link bridge window type OpenCodeWindow → CodevWindow (its __CODEV__ global was already renamed in the desktop row). These render in the desktop app (and the web console), which the fork ships; closes the "in-app i18n strings" gap for this package. Keep our strings; take upstream's surrounding changes. PRESERVED deliberately: the real external-service names "OpenCode Zen" / "OpenCode Go" (in the opencodeZen.* provider-connect keys and the "OpenCode Go" upsell), plus every opencode.ai/* URL — the fork routes to those actual services, so the display text must match what users land on. The built-in "OpenCode" theme name (packages/ui/src/theme/context.tsx, the shiki theme id, desktop-theme.schema.json) is also kept — it names a specific palette, and the fork ships its own codev theme alongside it.
Browser-opened docs-site links: packages/tui/src/app.tsx, packages/app/src/desktop-menu.ts, packages/app/src/components/{settings-general,settings-v2/general,dialog-custom-provider}.tsx, packages/app/src/pages/{home,layout,error}.tsx, packages/app/src/i18n/{en,es}.ts, packages/opencode/src/cli/cmd/{providers,github.handler}.ts Links the app opens in the user's browser repointed from opencode.ai to the fork's hosted docs. Each one derives its base from DOCS_URL (packages/codev-brand/src/index.ts, base64-encoded) rather than hardcoding the host: the site is locale-routed and the Code docs live under /code, and no URL takes a trailing slash (the site 308-redirects …/themes/ to …/themes). Sites: TUI "Open docs" command and app Documentation menu item → docs root; settings theme "Learn more" (v1 + v2) → …/code/themes; custom-provider dialog link → …/code/providers#custom-provider; CLI Cloudflare-gateway hint → …/code/providers#cloudflare-ai-gateway; GitHub-agent hint → …/code/github; home/layout sidebar Help buttons opencode.ai/desktop-feedback → docs root. The error page's report button opencode.ai/desktop-feedback → github.com/quickbeard/codev-code/issues instead (it's a report action, not a docs lookup), icon discord → github, and the en/es error.page.report.* strings → "…CoDev team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → github.com/quickbeard/codev-code/issues/new?template=feature-request.yml/bug-report.yml (the fork's template names are hyphenated, unlike upstream's underscored ones). Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (opencode.ai/zen, /go, /auth), $schema/theme-schema ids, API/share/changelog endpoints, MCP OAuth client_uri, prompt-file mentions. Also NOT on this base: the ripgrep mirror (/codev/docs/code/downloads/…, see its own row) — it is a public/ static asset, not locale-routed content, and 404s under /en/docs/code. Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened opencode.ai docs/help link to the fork docs base; leave the NOT-repointed list.
packages/desktop/src/main/index.ts, packages/desktop/src/main/windows.ts APP_NAMES + window title → "CoDev" (Dev/Beta variants too) Desktop app display name ("Code" dropped once the app carried a Work section beside Code). Keep our names.
packages/desktop/electron-builder.config.ts productName ×3 → "CoDev…"; artifactName → codev-code-desktop-… (deliberately kept when the product name dropped "Code" — the update feed's latest*.yml and the hand-upload flow reference these artifact names, and nothing user-visible shows them); protocol display name. Auto-update publish repointed off GitHub entirely: upstream's beta anomalyco/opencode-beta and prod anomalyco/opencode collapse to one shared generic config (const publish) pointing at the fork's MinIO bucket — url from process.env.CODEV_UPDATE_URL falling back to a base64 UPDATE_URL const, deliberately unversioned — it is the only URL an installed build ever reads (app-update.yml is baked at package time and nothing calls setFeedURL), so a version in it would leave that build able to see only its own release folder. The manifests sit at that base and the artifacts they name live under <version>/, which the fork-only scripts/stage-release.ts arranges by prefixing each url/path in latest*.yml; electron-updater resolves them with new URL(name, base) (same atob convention as packages/codev-brand/src/index.ts), plus two fields that are load-bearing rather than cosmetic: channel: "latest" and useMultipleRangeRequest: false. Also a second extraResources entry shipping ../opencode/dist/node → server-bundle (filter node.js + *.wasm, dropping the sourcemap, which outweighs the bundle) so the @codev/sandbox providers have a real on-disk server directory to mount into guests; src/main/sandbox/bundle.ts (fork-only) resolves it under process.resourcesPath by that name. Also the sandbox vz helper: mac.extraResources ships packages/codev-sandbox/native/vzhelper/dist/codev-vmm (built universal by scripts/prebuild.ts) as Resources/codev-vmm, and an afterSign hook (signVmmHelper) re-signs it with vzhelper.entitlements (com.apple.security.virtualization — an entitlement the Electron app itself must not carry) under the same identity that signed the app, hardened runtime on, timestamp only for non-ad-hoc, before electron-builder's built-in notarization runs. Also nsis is an assisted installer rather than upstream's one-click: oneClick: false + allowToChangeInstallationDirectory: true so the user can take the default location or browse for their own, perMachine still false, and installerHeaderIcon dropped (electron-builder reads it only in the one-click branch of configureDefines; assisted wizards take installerHeader/installerSidebar bitmaps, and without them fall back to the stock NSIS sidebar). Plus two installer-text changes: uninstallDisplayName: "${productName}" drops the version from the Apps & Features name (electron-builder defaults to ${productName} ${version}, and that list already has a Version column fed by the separate DisplayVersion the installer writes, so the default prints it twice), and warningsAsErrors: false exists solely to permit the fork-only resources/installer.nsh — see the on-conflict column. Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: quickbeard/codev-code is private and GitHubProvider.getLatestTagName requests /releases/latest unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. channel: "latest" is required, not a default restated: detectUpdateChannel is on by default, so with it absent getResolvedPublishConfig takes the channel from AppInfo.channel, which is the version's first semver prerelease component — 8 for 1.18.4-8 — and getUpdateInfoFileName then writes 8.yml/8-mac.yml/8-linux.yml while the app requests latest.yml (autoUpdater.channel in src/main/updater.ts), i.e. a permanent ERR_UPDATER_CHANNEL_FILE_NOT_FOUND. useMultipleRangeRequest: false is required for MinIO, which answers a multipart range request with the whole object; electron-updater hardcodes the same false for its own s3 provider citing minio/minio#5285 but defaults it to true for any generic URL that is not s3.amazonaws.com. Beta keeps a publish block despite the fork shipping no beta installers because UPDATER_ENABLED covers that channel and a build with no publish config gets no app-update.yml at all, so every check would fail with ENOENT. ${channel} is deliberately NOT used as a URL macro — macroExpander expands it to that same AppInfo.channel (8), not CODEV_CHANNEL. appId is ai.codev.desktop{,.beta,.dev} and rpm.packageName is codev/codev-beta/codev-dev — see the desktop identity row for the full rename and what it costs. Also extraMetadata.name, single-sourced with rpm.packageName as PACKAGE_NAMES: package.json name stays @opencode-ai/desktop per the internal-names rule, but electron-builder does not treat it as internal — appInfo.sanitizedName strips the slash to @opencode-aidesktop and that reaches users as the updater cache directory (<name>-updater, baked into app-update.yml on every platform) and as the source of the Debian package name when deb.packageName is unset. It named the per-user Windows install directory too (%LOCALAPPDATA%\Programs\@opencode-aidesktop) until the installer became assisted: `getWindowsInstallationDirName(appInfo, !oneClick
packages/app/src/utils/refcount.ts (+ .test.ts) createRefCountMap creates each item in its own root owned by the scope that created the map (disposed when the last pinning caller releases it), and callers without a reactive owner borrow the item without pinning it. Upstream ran create in the first caller's scope, so a shared item's internal cleanups (e.g. the dir SDK context's event unsubscribe in server-sdk.tsx) fired when that first view closed while other holders still used the item — live updates for the directory silently died. Ownerless callers (event handlers, async continuations: notification.tsx session lookup, layout.tsx openSession) also registered cleanups Solid warns can never run and pinned items forever. Keep ours; take upstream's surrounding changes. The two added tests ("ownerless caller borrows without pinning", "item's internal cleanups survive its first caller") pin the semantics.
packages/app/src/app.tsx, packages/app/src/utils/session-route.ts session-route.ts gains serverKeyFromRoute, a non-throwing variant of requireServerKey (which is now defined in terms of it and kept for callers that want the throw). app.tsx's TargetServerRoute and LegacyTargetSessionRoute read the route's server key through it: TargetServerRoute holds it in a memo, passes undefined rather than a bogus connection to the SDK/sync providers, and falls back to <Navigate href="/" />; LegacyTargetSessionRoute wraps its SessionRouteErrorBoundary in a Show on the same memo. requireServerKey throws on a segment that is missing or does not round-trip, and both call sites ran it during render — so the throw escaped from a memo and killed the entire renderer with the fatal error screen. The param does go missing in normal use: removing a server navigates away (tabs.removeServer → navigate("/")) and the route's params clear before its subtree unmounts, which is how deleting a sandbox with an open session took the window down. Worse, the desktop restores the last active route on launch, so the crash repeated on every relaunch until the persisted URL changed. Rendering nothing was not enough on its own — a /server/… route that cannot mount its providers leaves the shell half-built and fails further down in the command registry — hence the redirect home. This is the same class as the project-avatar-state.ts guard below and is an upstream bug. Keep the guard; take upstream's other changes to these files. If upstream makes the route resilient its own way, drop ours in favour of theirs. Do not "simplify" the route back to calling requireServerKey directly — that is the crash.
packages/app/src/context/server-sdk.tsx Event-stream failures log only after 3 consecutive failed attempts (logStreamIssue), with the error normalized to name: message; any received event resets the count. The first failed attempt usually races a server being stopped or restarted (sandbox reset, sidecar relaunch): the stream dies before the renderer learns the server is going away and the context is disposed before a real retry, so logging attempt one produced a spurious [global-sdk] event stream error for normal lifecycle. A genuinely unreachable server still logs within about a second (250 ms reconnect delay). Keep the threshold and shared logStreamIssue; take upstream's surrounding stream changes.
packages/app/src/context/command.tsx The DEV duplicate-command-id warning fires only for duplicates still present 1 s later (warnIfPersistentDuplicate); runtime keep-first behavior unchanged. Router transitions keep the outgoing page mounted while the incoming one registers, so ids both pages provide (command.palette, input.focus) are briefly duplicated on every draft→session navigation; only a duplicate that survives the transition is a registration bug. Keep ours; take upstream's surrounding command changes.
packages/desktop/src/main/logging.ts Added initChromiumNoiseFilter: an electron-log hook dropping two benign Chromium intervention messages (ResizeObserver loop, autofocus blocked) from the console transport for renderer-spied messages; file logs keep them. The spyRendererConsole pipe surfaces Chromium interventions the app can neither cause nor fix as red terminal lines during bun dev:desktop. Keep the hook; take upstream's surrounding logging changes.
packages/app/src/context/global-sync/utils.ts normalizeProviderList returns through filterHiddenProviders (fork-only src/gateway/hidden-providers.ts): upstream's hosted providers opencode (Zen) and opencode-go are dropped from all/default, and the unauthenticated free-tier opencode from connected (a deliberately configured paid Zen key still counts as connected, mirroring the TUI's isConnectedProvider). Client-side hiding at the one choke point both the global bootstrap and per-directory catalogs flow through — mirrors the TUI's dialog filters without writing disabled_providers. Downstream surfaces keyed off those catalog entries (dialog-select-model-unpaid{,-v2}.tsx, the usage-exceeded-dialogs.tsx Go upsell, Zen branches in dialog-connect-provider.tsx, hosted entries in use-providers.ts popularProviders, settings badges) become deliberately-dead code left byte-identical to upstream so merges stay cheap. Keep the import + wrap; take upstream's normalization changes. Do not "clean up" the dead hosted-provider surfaces — they cost nothing and would churn every sync.
packages/ui/src/context/dialog.tsx Per-dialog persistent flag (precedent: the TUI's persistent dialog, see the packages/tui/src/ui/dialog.tsx row): Escape, backdrop click, and Kobalte onOpenChange are no-ops for a dialog marked persistent; programmatic close() still works; setPersistent targets the top dialog so pushed sub-dialogs stay dismissable; useDialog() gains persistent/setPersistent. The desktop configure gate needs a dialog the user cannot dismiss while no provider is configured. Keep the flag and the three guards; take upstream's surrounding dialog changes.
packages/app/src/app.tsx <GatewayConfigureGate /> (fork-only components/gateway/) mounted as the first child inside ServerScopedProviders' ModelsProvider. The desktop configure gate; the placement is load-bearing — completing the flow seeds the live useModels() recents store (buildGatewayConfig deliberately writes no config.model pin). Keep the mount inside ModelsProvider; take upstream's surrounding shell changes.
packages/app/src/context/platform.tsx Added optional gatewaySso?: GatewaySsoPlatform capability (wire types in fork-only src/gateway/types.ts, exported as @opencode-ai/app/gateway/types — see the packages/app/package.json row). Desktop-only SSO login for the configure gate; absence of the capability is the web deployment's kill-switch. Keep the field + import; take upstream's surrounding platform changes.
packages/app/src/components/prompt-input-v2.tsx onUnpaidClick opens DialogConnectProvider (lazy import) instead of the free-models "unpaid" dialogs; their now-unused imports dropped. (The legacy prompt-input.tsx carried the same swap until its deletion — see the legacy prompt-input removal row.) With the hosted providers hidden, "unpaid" means nothing is connected, and the free-models dialogs would render empty lists (this path is reachable on the web deployment; on desktop the gate covers it). Keep the handler swap; take upstream's surrounding prompt-input changes.
packages/desktop/src/renderer/index.tsx Calls installConsoleFormatting() (fork-only ./console-format) before anything else logs. spyRendererConsole receives Chromium's flattened console-message string, so any renderer console.* with an object argument reached the terminal and the exported debug logs as [object Object]; the wrapper serializes structured arguments (Error-aware, circular-safe, truncated) at the source. Keep the import and call; take upstream's surrounding entry changes.
Desktop gateway SSO wiring: packages/desktop/src/main/index.ts, packages/desktop/src/preload/{index,types}.ts, packages/desktop/src/renderer/index.tsx Main instantiates the fork-only src/main/gateway-sso/ controller (wrapping @codev/gateway login/silentSso — node-only loopback OAuth — with shell.openExternal and a net.fetch wrapper) and registers its gateway-sso-* IPC channels next to the sandbox ones, passing an onConfigured callback that restarts the running sandboxes (SandboxController.restartRunning) because a guest is handed its codev config once at boot and cannot see credentials that appear afterwards; preload exposes the gatewaySso namespace (subscribe fan-out, sandbox pattern) with types re-exported from @opencode-ai/app/gateway/types; the renderer platform maps gatewaySso: window.api.gatewaySso. SSO login for the desktop configure gate must run in the main process (node:http loopback server, ~/.codev/auth.json writes); net.fetch so enterprise proxies/PAC apply (egress precedent). Keep the wiring; take upstream's surrounding changes. Keep channel names gateway-sso-* stable.
Skill Hub app wiring: packages/app/src/app.tsx, packages/app/src/i18n/{en,es}.ts app.tsx registers lazy /skills + /skills/stacks + /skills/:id routes inside the new-layout <Show> (/skills/stacks before /skills/:id); i18n adds sidebar.skillhub + the skillhub.* and skillstack.* families (vi.ts is fork-only). The Skill Hub pages are native and fork-only (packages/app/src/{skillhub/**,pages/skill-hub*.tsx,pages/skill-stacks.tsx,components/skill-hub-nav.tsx}) and talk to the connected server's fork-only v2.skillhub routes: the hub serves no ACAO on /api/v1, so a browser cannot reach it and the server proxies it — which also puts the install on the host that runs the agent. The earlier Electron IPC bridge (packages/desktop/src/main/skillhub/, a preload skillhub namespace, platform.skillhub) is gone; nothing desktop-side remains. Keep the three route lines and the i18n keys; take upstream's surrounding changes.
packages/app/vite.js Added optimizeDeps.entries: the default index.html plus session-ui's markdown.worker.ts (absolute path derived from the plugin file, forward-slashed for fast-glob), so the dev-time dep scanner crawls the markdown worker at startup. Vite's dep scanner does not crawl ?worker&url entries, so the worker's deps (@shikijs/stream, katex, marked, marked-shiki, remend, shiki) were first discovered when the first markdown rendered; that mid-session re-optimize full-page reloads the dev app — e.g. a Skill Hub search reset itself the moment results rendered. A bare optimizeDeps.include list cannot express this fix: with bun's isolated linker the deps live only in the workspace packages' own node_modules, and no single include chain resolves from both consumers' roots (packages/app and the desktop renderer's src/renderer). Dev-only; production builds ignore optimizeDeps. Keep the optimizeDeps block and keep index.html listed in it — setting entries replaces Vite's default entry inference, so dropping it unscans the app itself. If upstream moves or renames the worker file, update the path: a stale pattern matches nothing, logs nothing, and silently brings the reload back.
packages/app/package.json Added "./gateway/types": "./src/gateway/types.ts" and "./i18n/*": "./src/i18n/*.ts" to exports. The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern); the desktop main process imports the locale dicts to translate the macOS menu bar (./i18n/* mirrors how @opencode-ai/ui already exports its own — see the localized desktop menu row). Keep both exports; take upstream's other manifest changes.
packages/app/src/i18n/{en,es}.ts Added the gateway.configure.* key family (configure-gate dialog) and reworded sidebar.gettingStarted.line1 — the "includes free models" pitch no longer holds with the hosted providers hidden. Strings for the fork's configure gate; the getting-started card must not advertise models the fork does not offer. Keep our keys and copy; take upstream's surrounding i18n changes.
packages/desktop/src/main/index.ts Deleted upstream's app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>"). Chromium's <-loopback> bypass rule means the opposite of what it reads as: it subtracts the implicit rules that keep loopback off the proxy, i.e. it opts localhost traffic into proxying (net/docs/proxy.md). Today it is inert — Electron reads --proxy-bypass-list only together with --proxy-server (shell/browser/browser_process_impl.cc), which the app never passes — but it becomes actively harmful the moment anything launches the app with --proxy-server: the renderer's sidecar traffic would be routed into the proxy. Chromium already bypasses loopback by default, so nothing replaces it. Keep deleted. If upstream adds new proxy switches, check them against Chromium's implicit-bypass semantics before taking them.
Startup proxy-stall gate: packages/desktop/src/main/{index,ipc}.ts, packages/desktop/src/preload/{index,types}.ts, packages/desktop/src/renderer/index.tsx, packages/app/src/app.tsx, packages/app/src/context/{platform,server-sdk}.tsx, packages/app/src/utils/server-protocol.ts (+ .test.ts), packages/app/src/context/global-sync/utils.ts (+ bootstrap.test.ts) Five additive pieces. (1) main/index.ts fires session.defaultSession.resolveProxy(url) for the sidecar URL as soon as the port is known (fire-and-forget, result logged). (2) A local-server-health IPC chain: main answers with the Node-fetch checkHealth against the spawned sidecar once serverReady resolves (false if initialization failed), preload exposes localServerHealth(), the renderer platform maps it, and platform.tsx gains the optional localServerHealth?() capability. (3) app.tsx ConnectionGate: the health-check effect is restructured around an inner poll; when the 10 s blocking window expires without a healthy answer and the connection is the built-in sidecar, it asks platform.localServerHealth — if the host process vouches for the server it keeps blocking on the splash for up to another 50 s instead of showing the unreachable error. Background mode and type: "http" behavior are unchanged, and without the capability (web) the gate behaves exactly as upstream. (4) A local-server-protocol IPC chain beside the health one: main answers with the SIDECAR_VERSION it launched, preload exposes localServerProtocol(), the renderer platform maps it, platform.tsx gains the optional localServerProtocol?() capability, and server-sdk.tsx uses it in place of detectServerProtocol when the connection is ServerConnection.builtin. (5) server-protocol.ts probe reports whether the server answered at all (reached); detectServerProtocol returns "v1" when neither health probe got any response (a timeout or transport failure) and keeps upstream's "v2" default for a server that answered with neither health shape. global-sync/utils.ts normalizeAgentList reads agent.request.settings optionally (?., ?? {}) — this repo's V2 Provider.Request schema has no settings, so the previous unconditional read threw on the first V2 agent. Chromium queues every renderer request — loopback included — until proxy initialization (PAC download / WPAD discovery) resolves; the implicit loopback-bypass rules are only applied once the resolution service is READY (net/proxy_resolution/configured_proxy_resolution_service.cc). On corporate networks that takes tens of seconds (measured 21 s vs 64 ms with an unreachable PAC host), so users behind a proxy saw "Could not reach Local Server" at every launch while the server was healthy the whole time — main's own Node-fetch health check (loopback in NO_PROXY) had already passed before the window opened. The prewarm overlaps proxy init with sidecar startup; the gate fallback distinguishes "server down" from "renderer network stack not ready". The same stall hit detectServerProtocol after the gate: its two 5 s renderer probes timed out and its fallthrough answered "v2" for the v1 sidecar, so the app bootstrapped over the transitional /api routes — /api/model/default does not exist on v1 (the provider query failed with UnexpectedStatus, connected stayed empty and the configure gate reopened after every successful sign-in) and the V2 agent payload has no request.settings (normalizeAgentList threw "reading 'temperature'"). Users behind a corporate proxy could log in and pick a model but never leave "Set up CoDev". Main knows which sidecar it launched, so the desktop no longer probes for it; the detector's silence rule covers remote servers reached through the same stall. Keep all five pieces; take upstream's surrounding changes. If upstream restructures ConnectionGate, re-apply the vouch-then-extend fallback around whatever its blocking window becomes. Keep the local-server-health and local-server-protocol channel names stable. If upstream changes detectServerProtocol's fallthrough, keep silence (no response at all) mapping to "v1" while every server this fork ships answers /global/health. If upstream's V2 agent schema gains request.settings, the optional reads in normalizeAgentList become no-ops and can be dropped.
packages/desktop/src/main/updater.ts autoUpdater.allowDowngrade true → false; autoUpdater.quitAndInstall() → quitAndInstall(true, true). Upstream's true is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale latest.yml would actively downgrade every client on the next 10-minute poll. The quitAndInstall arguments are load-bearing against the assisted installer (see the electron-builder.config.ts row): the defaults are isSilent = false, isForceRunAfter = false, and NsisUpdater.doInstall appends /S only when isSilent — so with an assisted installer the default would run the full wizard on every update, re-asking install mode and directory for a location the user already chose. isForceRunAfter is honoured only when isSilent is set (BaseUpdater.quitAndInstall passes autoRunAppAfterInstall instead when it is not), so both arguments are needed to install quietly and come back up. Keep false; take upstream's other updater settings. Keep quitAndInstall(true, true) for as long as nsis.oneClick is false; the two only make sense together. channel = "latest" and allowPrerelease = false stay as upstream has them — the first must match publish.channel in electron-builder.config.ts, the second is inert for the generic provider (it only steers GitHub tag resolution).
Unsigned macOS install path: packages/desktop/src/main/updater.ts, packages/desktop/src/main/updater-controller.ts, packages/desktop/src/main/updater-controller.test.ts, packages/app/src/i18n/{en,es,vi}.ts, packages/app/src/i18n/desktop-native.ts UpdaterBackend gains an optional prepareInstall() (run before stop()), quitAndInstall() may return a promise, and install() transitions to error with the message on failure instead of silently back to ready. updater.ts remembers update-downloaded's downloadedFile; on macOS prepareInstall reads the running bundle's signature with codesign and, unless it carries a real Authority= chain, stages the update through the fork-only mac-update.ts (ditto extract, then a sibling .CoDev.app.update); quitAndInstall then spawns the detached swap script and calls app.quit() instead of autoUpdater.quitAndInstall. New native key desktop.updater.error.downloadMissing. Squirrel.Mac validates an update against the running app's designated requirement, which only a real signing identity provides. Fleet builds have no Apple Developer ID, so electron-builder leaves Electron's linker ad-hoc signature and ShipIt fails with Code signature ... did not pass validation: code object is not signed at all — on an error event nothing listened to, so "Install and restart" did nothing while the sidecar had already been stopped. Windows (verifyUpdateCodeSignature: false) and AppImage verify nothing, hence macOS-only. Preparing before stop() keeps a failed install from leaving a dead app. Keep the prepareInstall hook and the error transition; take upstream's other controller changes and re-apply the order prepare → stop → quitAndInstall if upstream rewrites install(). The gate self-disables for Developer-ID-signed builds (readCodeSignature returns signed and Squirrel is used), so the path can stay even if signing arrives; drop it only if unsigned builds are no longer shipped.
Updater dialogs: packages/desktop/src/main/updater.ts, packages/app/src/i18n/{en,es,vi}.ts, packages/app/src/i18n/desktop-native.ts showUpdaterDialog names versions and speaks the user's language. Every branch gains a detail line carrying app.getVersion(), and the available state (where a check stops by default) gains a consent dialog whose Update Now button calls controller.downloadAndInstall() fire-and-forget — the menu asks the same question the launch prompt does, so it has to mean the same thing: download, install, restart. Its detail says so. Since the v1.18.19 sync the strings come from the desktop-native registry through nativeT (upstream's mechanism); the fork adds the keys upstream lacks — desktop.updater.dialog.{checkFailed,upToDate,ready}.detail, desktop.updater.dialog.available.*, desktop.updater.dialog.updateNow — to DESKTOP_NATIVE_ENGLISH and the es/vi dicts. "Check for Updates..." is asked precisely to answer "what am I on, and what would I move to"; the available state previously closed showing nothing. Keep the version details, the available branch (including its downloadAndInstall call — reverting it to download() leaves the menu path stopping halfway while the launch prompt finishes the job), and the fork registry keys; take upstream's surrounding updater changes. New dialog copy needs a registry key plus es/vi dict entries, and {{placeholder}}s must survive translation (placeholders.test.ts).
Dual-path WSL install: packages/desktop/src/main/wsl/{runtime,servers}.ts runtime.ts: three internals gain export (runPowerShell, runInteractiveCommand, resolveSystem32Command) — no behavior change. servers.ts: the install-wsl job calls installWslRuntimeDualPath and the install-distro job calls installWslDistroDualPath (both from the fork-only install-bundle.ts, which holds all new logic) instead of installWslRuntimeElevated / installWslDistro directly; the install-wsl job additionally treats exit 3010 (WSL_INSTALL_REBOOT_REQUIRED, re-raised by the fork's elevated script) as success with pendingRestart: true rather than as a failure — wsl --version answers from the freshly installed MSI before the just-enabled VirtualMachinePlatform is active, so no post-install probe can detect the pending reboot; otherwise both return the same WslCommandResult shape and the surrounding job/error handling is untouched. The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads from the codev storage host — the runtime as a per-architecture MSI named by its own manifest (/codev/installers/wsl/manifest.json, assets under /codev/installers/wsl/<version>/) and staged through stageAsset for resume + sha256 verification, the distro from the pinned wsl.zip — enables VirtualMachinePlatform and Microsoft-Windows-Subsystem-Linux via offline dism + installs the MSI behind one UAC prompt (Windows 10 requires the WSL optional component even for the standalone MSI — without it every wsl distro command fails with Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED; on machines that already have it dism is a no-op), and falls back to upstream's wsl --install / --web-download paths only when the download cannot be obtained — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. Keep the two call swaps, the 3010 handling in install-wsl, and the three export keywords; take upstream's surrounding changes. If upstream reworks installWslRuntimeElevated / installWslDistro, keep them exported from runtime.ts — install-bundle.ts calls them as the fallback path.
packages/desktop/package.json Added the test script (bun test src --only-failures). The package had 21 test files and no way to run them: with no test script, bun turbo test skipped it and every desktop test was dead weight. Scoped to src (like @opencode-ai/ui) so the built out/ tree is never scanned. Paired with the turbo.json row — the script alone does not enrol the package in CI. Keep ours. If upstream adds its own test script, take theirs only if it covers src.
Async settings stores: packages/desktop/src/main/{store,index}.ts, packages/desktop/package.json store.ts no longer uses electron-store: getStore now returns the fork-only store-file.ts store — in-memory state, flushed write-behind through store-writer.ts's serialized latest-wins async atomic writer (temp → fsync → rename via fs.promises, so the fsync runs on the libuv threadpool). Adds flushStoreWrites(timeoutMs); removeStoreFile/removeStoreFileIfEmpty discard/flush the writer around the delete so a late flush cannot resurrect the file. index.ts: stopSidecars awaits flushStoreWrites(1500). package.json: the electron-store dependency is removed. On-disk format is unchanged (flat keys, tab-indented JSON, no extension) — existing files load as-is; a corrupt file now starts empty with a logged error instead of throwing from every accessor. conf v15's store.set is a synchronous fsync on the Electron main thread, and one of those parked the whole app in uninterruptible disk sleep (kernel journal stall + lost ext4 fast-commit wakeup, observed live during a sandbox mount-apply restart): event loop dead, IPC dead, quit impossible. conf also rereads the file on every access, which makes write-behind unsound with the stock class (a set on one key reads a stale file and drops a still-queued update), so the storage layer had to be replaced rather than patched. Keep the fork store; take upstream's surrounding changes. If upstream adds electron-store options (schema, migrations, dot-notation) or new store call sites, extend store-file.ts to match — its API is the superset the desktop actually uses (get/set/has/delete/clear/store/path). Do not reintroduce a synchronous write path on the main thread.
Desktop sandbox wiring: packages/desktop/src/main/{index,store-keys}.ts, packages/desktop/package.json Hooks the fork-only src/main/sandbox/{bundle,controller,ipc}.ts modules into startup: index.ts constructs the sandbox controller beside the WSL servers controller, adds sandboxes.stopAll() to stopSidecars, registers the sandbox-* IPC channels next to the WSL ones, and calls sandboxes.initialize() (all platforms) once the local server is ready; store-keys.ts gains SANDBOXES_KEY = "sandboxes"; package.json gains the @codev/sandbox and @codev/gateway workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). The desktop app is where sandboxes are created and run; createSandboxController generalizes the proven WSL-servers controller pattern to the per-platform @codev/sandbox providers (macOS vz / Windows wsl / Linux bwrap). Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from initialize() needing the local server ready first. Do not rename the sandbox-* channels or the sandboxes store key — preload/renderer and persisted sandbox configs depend on them.
packages/app/src/components/server/server-row-menu.tsx The trailing Delete item carries style={{ "--menu-v2-fg": "var(--v2-state-fg-danger)" }}. Nothing else changes — same disabled={builtin}, same handleRemove(key), same position after the separator. Delete rendered in the same colour as Edit and Set as default, so the one irreversible action in the menu carried no more weight than the rest. The session actions menu already colours its Delete this way; this brings the server menu in line. It has to be the CSS variable rather than a text utility: menu-v2.css colours the item and its menu-v2-item-content label span from --menu-v2-fg, so a class on the item never reaches the label. Keep the style prop. On conflict take upstream's item and re-apply it. Do not swap it for a Tailwind text colour — that silently only recolours half the item. The disabled state dims by opacity alone, so the red survives it.
Desktop sandbox renderer slice: packages/app/package.json, packages/app/src/{index.ts,app.tsx}, packages/app/src/context/{platform,server}.tsx, packages/app/src/components/{settings-v2/servers.tsx,dialog-select-server.tsx}, packages/app/src/wsl/settings.tsx, packages/app/src/i18n/{en,es}.ts, packages/desktop/src/preload/{index,types}.ts, packages/desktop/src/renderer/index.tsx Surfaces the fork-only sandbox modules (packages/app/src/sandbox/*, packages/desktop/src/renderer/sandbox/*) in the UI: app package.json exports ./sandbox/types (mirror of ./wsl/types); index.ts re-exports useSandboxes + the sandbox types; app.tsx mounts SandboxesProvider inside WslServersProvider; platform.tsx gains optional sandboxes?: SandboxesPlatform and, on the desktop variant, openFilePathDialog (a path-only file picker — the existing attachment picker reads the file's bytes, which is unusable for a multi-gigabyte guest image), implemented in renderer/index.tsx over the existing open-file-picker channel and releasing the read token immediately; server.tsx adds the third ServerConnection.Sidecar variant { variant: "sandbox"; name; projectDir?; mounts? } with key sandbox:<name> (projectDir is the host folder mounted into the guest and mounts the extra host folders with their resolved guest paths — the guest only ever sees its own mount points, so this is the only way the app can name a sandbox's projects after the folders it works on); settings-v2/servers.tsx filters sandbox rows out of the generic list and renders SandboxServerSettings beside the WSL section; dialog-select-server.tsx handleRemove routes sandbox: keys to platform.sandboxes.remove (like wsl: keys) and moves the tabs.removeServer(key) + server.remove(key) pair above both awaited platform removals; wsl/settings.tsx AddServerMenu shows the menu when either wslServers or sandboxes exist and gains an "Add sandbox" item; i18n gains the sandbox.* key family (including the optional per-sandbox CPU/memory fields, macOS-only since only the vz backend applies them; sandbox.add.projectDirHint, the standing explanation above the folder field; the sandbox.mounts.* family for the manage-folders dialog that adds/removes extra mounts on an existing sandbox; sandbox.job.queued, shown while a sandbox's create/reset/destroy waits its turn behind another sandbox's job; sandbox.server.stop for the menu entry that shuts a sandbox down until it is explicitly started again; the sandbox.add.image* / sandbox.add.name* / sandbox.arch.* families for the optional custom-image field and the sandbox-name checks; and sandbox.add.resourcesHint plus interpolated sandbox.add.{cpus,memory,resourcesInvalid}, which quote the vz defaults and ranges from the constants rather than restating them in prose); preload exposes api.sandboxes over the sandbox-* channels and renderer/index.tsx wires the platform api, loading gate, ready-connection list, and the startupServer fallback (fork-only wrapper that validates sandbox: default keys the way availableStartupServer validates wsl: ones). Ready sandboxes must appear as first-class servers and be manageable from settings; the slice mirrors the WSL vertical exactly so upstream conflicts resolve the same way in both. Removing a server destroys a disk and takes seconds, and the connection leaves the list the moment it finishes, so the tab cleanup has to happen before the await rather than after it. Keep every listed edit; take upstream's surrounding changes. If upstream adds a Sidecar variant or reworks AddServerMenu, re-apply the sandbox variant/menu item on top. The sandbox:<name> key format is persisted as the default-server key and as config.id in the main-process store — never change one side alone. Keep the tab cleanup ahead of the platform removals in handleRemove; if upstream reorders it back, re-apply.
One-click sandbox for Work: packages/app/src/context/server.tsx, packages/app/src/components/app-sidebar.tsx, packages/app/src/i18n/{en,es}.ts, packages/desktop/src/preload/index.ts Work's "you need a sandbox" gate opens the fork-only src/sandbox/dialog-add-default.tsx instead of DialogAddSandbox: it asks only for an optional custom image, and the main process decides everything else — the name (default-sandbox), the project folder, and the limits, which it leaves unset so the backend default applies. That folder is a placeholder the app invents (fork-only src/main/sandbox/workspace.ts, under %LOCALAPPDATA% on Windows because a redirected %APPDATA% can be a UNC path and wslpath cannot translate one), so SandboxConfig carries placeholderProject and the upstream files here keep it out of the user's way: server.tsx adds placeholderProject to the Sidecar sandbox variant, and app-sidebar.tsx renders the fork-only <SandboxMountPrompt> in place of upstream's HomeProjectEmpty for such a sandbox — upstream's picker browses the server it is pointed at, which for a sandbox is the guest's own home. The fork-only dialog-mounts.tsx hides its primary-folder row for the same sandbox: that path is app data the user did not choose and cannot remove, and the dialog would otherwise be the one place they meet it. preload/index.ts exposes sandbox-add-default and sandbox-cancel-default (its own channel because a sandbox mid-create is not in the list the renderer can see, so there is no name to cancel by). i18n gains sandbox.addDefault.* and sandbox.mountPrompt.*. Settings → Servers still opens the full DialogAddSandbox, unchanged. A user reaching Work for the first time was asked for a name, a folder, an image, a CPU count and a memory size before they could do anything, and the folder question has no good answer yet — they have not decided what to work on. Deciding it for them and asking for a folder afterwards, as a mount, matches what the sandbox actually is: an environment whose reach into the machine is the folders you give it. Keep the wiring and take upstream's surrounding changes. If upstream reworks the empty-projects fallback in app-sidebar.tsx, keep the placeholder branch ahead of it. Do not "simplify" the placeholder away by pointing the sandbox at the user's home or an existing project: the guest gets read-write access to its project folder. DEFAULT_SANDBOX_NAME is persisted as a sandbox id (sandbox:default-sandbox) and as a WSL distribution name — changing it strands existing sandboxes.
Sandbox guest durability: packages/core/src/database/database.ts, packages/core/src/flag/flag.ts PRAGMA synchronous is no longer hardcoded to NORMAL: it reads Flag.CODEV_SQLITE_SYNCHRONOUS, validated against a whitelist (OFF/NORMAL/FULL/EXTRA) because the value lands in a PRAGMA, and falls back to NORMAL — so nothing changes for any process that does not set it. The sandbox sets it to FULL for guests (fork-only packages/codev-sandbox/src/boot.ts). WAL + synchronous = NORMAL is durable across a crashed process but not across a machine that disappears, because those commits sit in the page cache until a checkpoint. A sandbox guest is exactly that machine: VZVirtualMachine.stop() is documented as pulling the power cable, and the guest gets no chance to sync — so stopping a sandbox, restarting one to apply a mount, or force quitting the app dropped every session written in the preceding seconds. Measured on a real vz guest: a file written and left unsynced is gone after a plain Stop/Start; with FULL a session created two seconds before kill -9 of the whole app survives. The knob lives in core because the guest runs the host's own server bundle — there is no sandbox-only build to patch. Keep the flag and the whitelist; take upstream's other pragmas. If upstream makes synchronous configurable its own way, drop ours for theirs. Never widen the whitelist to pass the value through unchecked. The default must stay NORMAL — raising it for every install would make the host pay for a guarantee only the sandbox needs.
packages/desktop/src/renderer/index.tsx Desktop notification icon https://opencode.ai/favicon-96x96-v3.png → the relative ./favicon-96x96-v3.png. The renderer's publicDir is packages/app/public, whose favicon-96x96-v3.png symlinks to the rebranded packages/ui/src/assets/favicon/ mark, so the file is emitted next to index.html and resolves against the renderer origin (oc://renderer/ packaged, the vite dev server otherwise) — the same asset and the same relative form src/renderer/index.html already uses for <link rel="icon">. A desktop app should not reach out to a remote host to draw its own notification icon (it fails offline and leaks a request per notification), and the remote file is upstream-branded while the bundled one is the codev mark. Keep our relative path. If upstream changes the URL, re-point it at the bundled asset rather than taking a new remote host. packages/app/src/entry.tsx and pages/layout/helpers.ts keep their remote opencode.ai/favicon* sources — same bug class, not yet fixed.

| Desktop identity: packages/desktop/src/main/{index,store}.ts, packages/desktop/electron-builder.config{,.test}.ts, packages/desktop/scripts/copy-metainfo.ts, packages/desktop/resources/linux/opencode-desktop.desktop (deleted), packages/app/src/pages/layout/{deep-links.ts,helpers.test.ts}, packages/codev-brand/test/brand.test.ts | appId ai.opencode.desktop{,.beta,.dev} → ai.codev.desktop{,.beta,.dev} (both copies: APP_IDS in main and in the builder config, plus the unpackaged fallback); deep-link scheme opencode:// → codev:// in all three places that must agree — protocols.schemes, app.setAsDefaultProtocolClient, and the renderer's parseUrl — plus the argv.startsWith filter the second-instance handler uses; rpm.packageName opencode/opencode-beta/opencode-dev → codev/codev-beta/codev-dev; the hidden opencode-desktop.desktop legacy launcher and its deb/rpm fpm wiring deleted. src/main/migrate.ts keeps the old ids — they name the Tauri app's directories, not ours. | The appId is the whole desktop identity: app.setPath("userData", join(appData, appId)), so it also names the data directory, and it drives the macOS bundle id, the Windows AppUserModelId and NSIS keys, the Linux .desktop filename, executableName and StartupWMClass, and the AppStream metainfo id. Leaving it as opencode meant the fork's app identified itself as upstream to the OS. Renamed as a clean cut with no data migration and no back-compat shim, on the explicit basis that there is no installed base: existing profiles under appData/ai.opencode.desktop* are simply orphaned (settings, session history, sandboxes and the downloaded guest image), and a packaged update across the rename would install beside the old app rather than replace it. The legacy launcher went with it — it existed to rescue GNOME/KDE pins from a superseded packaging id, and its Exec=/opt/OpenCode/... no longer matched any install layout. | Keep our ids and scheme; take upstream's other config. Never rename the TAURI_APP_IDS in migrate.ts to match — a blind find-and-replace there points the Tauri migration at a directory that never existed. All three deep-link sites have to move together or links open nothing; electron-builder.config.test.ts covers the ids, the package names and the scheme per channel. |

| Work loading state: packages/app/src/pages/home.tsx, packages/app/src/i18n/{en,es}.ts | NewHome gains useWorkspaceSide() + useSandboxes() and a waitingForSandbox() memo over the fork-only packages/app/src/workspace/waiting.ts, which drives two things. (1) The session list's empty fallback becomes a Show: while Work waits it renders the fork-only HomeSandboxLoading — a Spinner at size-8 over the workspace.loading.sandbox label, centred in the pane — and otherwise upstream's HomeSessionsEmpty, unchanged. (2) HomeSessionSearch is wrapped in <Show when={!waitingForSandbox()}>. New i18n key workspace.loading.sandbox. HomeSessionSkeleton and the Code-side loading branch that uses it are untouched. HomeSandboxLoading carries the wrapper's own min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)] rather than min-h-full: the wrapper has a min-height and an auto height, so a percentage would resolve against the content and centre nothing. | A sandbox takes minutes to create and seconds to boot, and for all of it the session list said "Nothing here yet / Create a session to get started" over a search box — the one action offered could not work, since the server that would run the session did not exist yet, and the search had nothing to search. Its own view rather than the session skeleton: that skeleton stands in for rows about to arrive, which is true of a server's session load and false here, where nothing arrives for minutes. | Keep the memo, the wrapping Show around the fallback, and the Show around HomeSessionSearch; take upstream's surrounding changes. HomeSessionsEmpty must stay reachable — it is all Code ever renders here. If upstream restructures the session pane, re-apply both Shows; they are independent. | | Per-folder tab strip: packages/app/src/context/{layout,tabs}.tsx, packages/app/src/components/titlebar.tsx, packages/app/src/pages/home.tsx | Extends the Code/Work split row above so each side keeps one tab strip per folder of the selected server rather than one for the whole side — shipped for Work first (a sandbox exposes its project folder plus every extra mount as its own project, fork-only src/sandbox/projects.ts), then extended to Code, whose folders are the selected server's projects. The strip is keyed by (server, folder); on web, which has no Code/Work split, useWorkspaceScope resolves no selection and the strip stays upstream's single list. All of the logic is in the fork-only src/workspace/side.ts (TabScope, folderForDirectory, tabsForScope, reorderWithinScope — tabsForSide/reorderWithinSide become thin wrappers over the same primitives so the Code path is provably unchanged) and src/workspace/tabs.tsx (useWorkspaceScope, useWorkspaceFolder). Upstream files hold wiring only: tabs.tsx adds SessionTab.directory — deliberately not part of tabKey(), which is what keeps every persisted info/memory/closed key valid as the field appears — plus backfillTabDirectories and the effect that applies it, promoteDraft inheriting the draft's directory, addSessionTab filling a missing directory on an already-open tab, and an optional within on closeTab/removeTab so the successor after a close is picked from the visible list, plus removeDirectory({server, directory}) — every tab of one mounted folder, sessions and drafts, for when that folder is unmounted (matching lives in the fork-only src/workspace/tab-directory.ts, its own module because both context/tabs.tsx and workspace/side.ts reach the router at import time and so cannot be unit-tested). Modelled on removeServer rather than closeTab: the folder left the sandbox, so the tabs are not a close the user can undo, and leaving them behind is what made an unmounted folder's session reappear over the next folder the user mounted — inScope() shows a tab whose folder the sandbox no longer lists in every folder, so it followed the user around while appearing in no folder's Recents; layout.tsx adds home.lastFolderSession (keyed `${server}\n${pathKey(directory)}`) with folderSession/recordFolderSession; titlebar.tsx swaps the side filter for the scope in the strip, reorder, tab.prev/tab.next and tab.close, passes session.directory to addSessionTab, and gains a leading scope branch in openNewTab; home.tsx passes directory to addSessionTab. pages/home-model.tsx (fork-only) selects instead of toggling on desktop — both sides — and calls enterFolder; web keeps upstream's toggle. The scope is derived, never stored: the folder on screen wins, then the sidebar's selected project, then the side's default folder (defaultFolder in workspace/tabs.tsx — the sandbox's primary folder from sync.data.path.directory on Work, the last-used project then the first on Code) — so deep links, the command palette and a reopened tab all pull the strip to their own folder instead of leaving the active tab out of view. pages/session.tsx and components/command-palette.ts are deliberately left untouched; their tabs pick up a folder through the back-fill instead, which keeps two more upstream files out of this table. | Sandboxes gained extra folder mounts, so one sandbox can expose several unrelated folders while their sessions shared a single strip — working across two folders meant constantly reading past the other one's tabs. Deriving the scope rather than persisting a "current folder" makes "the strip always contains the session you are looking at" true by construction instead of by an effect racing the two home effects in layout.tsx, and it keeps the sidebar's Recents unnarrowed: writing the route's folder into home.selection.directory would feed projectDirectories() and silently filter that list too. | Keep the wiring and take upstream's surrounding changes. tabsForScope must return the store's own tab objects — the strip compares by reference for the active highlight, the close index and the <For> key, so never map to copies. Never add directory to tabKey(). A tab whose folder cannot be resolved shows in every folder of its own server on purpose: it is exactly the tab that has never rendered and so never had a chance to learn where it belongs, and being invisible everywhere is unrecoverable. folderForDirectory's prefix test must stay path-boundary aware — mount guest names dedup with a -2 suffix, so /mnt/shared must not swallow /mnt/shared-2. Resolve the primary folder from sync.data.path, never projects.list()[0]: projects.open() prepends, so that is the most recently added mount. | | Code/Work workspace split: packages/app/src/context/layout.tsx, packages/app/src/components/{app-sidebar,titlebar}.tsx, packages/app/src/i18n/{en,es}.ts | The sidebar gains two tabs above Projects — Code (every non-sandbox server: the local sidecar, WSL, remote, SSH) and Work (the sandboxes) — backed by the fork-only packages/app/src/workspace/{side.ts,tabs.tsx}. layout.tsx stores home.workspace and home.lastSession (per side) and records the latter from an effect on route(); app-sidebar.tsx renders <WorkspaceTabs/>, swaps global.servers.list() for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; titlebar.tsx loses the Home button and its home.toggle command (mod+b) and filters the tab strip, its reorder and tab.prev/tab.next to the visible side; effectiveSide falls back to Code wherever the Work side is unavailable — originally only where the platform has no sandbox support, now workSideAvailable()'s answer (see the opt-in Work row below), which still never collapses on an unloaded sandbox list, since that is indistinguishable from an empty one; layout.tsx also holds an effect keeping home.selection.server on the showing side, because a sandbox joins the server list only once it has finished starting, so the one-shot correction inside a switch cannot cover a sandbox created moments earlier; the fork-only DialogAddSandbox gains an onCreated callback so the Work gate switches on the create's own result rather than on the asynchronous sandbox list; dialog-select-server.tsx handleRemove calls workspace.removedServer(key) beside the tab cleanup, which steps back to Code when the sandbox being deleted is the last one; resuming a side's last session also selects the project that session lives in (sessionProjectSelection in workspace/side.ts, which maps the session's directory to the owning folder of the server's project list) and touches it as that server's last project — the switch otherwise set a server with no directory and returned, leaving the sidebar on no project at all beside a restored session; i18n gains workspace.{code,work,empty.none,empty.starting,empty.stopped,empty.failed,empty.failed.retry} (empty.stopped for a Work side whose sandboxes are all stopped, which unlike starting will never come up on its own; empty.failed/empty.failed.retry for one whose sandbox failed to start — the empty state shows the runtime's failure message and a retry that calls platform.sandboxes.start, because the wait message otherwise left the user watching for a sandbox that already gave up). | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six sideServers() call sites in app-sidebar.tsx were global.servers.list() — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read useWorkspaceSide() rather than layout.home.workspace(): it collapses to Code wherever Work is unavailable, which is what keeps the web build — and a sandbox-less desktop profile — coherent. The tab strip filter and reorderWithinSide go together — tabs.reorder() rejects a partial key list, so a filtered strip without the merge silently ignores drags. | | Workspace (worktree) bar is Code-only: packages/app/src/pages/new-session.tsx | showWorkspaceBar gains a third condition, sideForServer(serverSDK().server) === "code", so the "Run session in → Local repository / New workspace / Workspace…" chip is not offered on Work. Work falls through to the same PromptGitStatus branch a non-git project already takes, which shows the branch on its own. Nothing else moves: newSessionWorktree() already returns "main" whenever the bar is hidden, and "main" in prompt-input/submit.ts means "leave sessionDirectory at sdk().directory" — it does not redirect a session to the project root. The bar remains gated on VITE_CODEV_CHANNEL !== "prod", which is upstream's own gate. | A workspace is a git worktree created by the server that runs the session, and Worktree.makeWorktreeInfo roots it at Global.Path.data/worktree/<projectID> (packages/opencode/src/worktree/index.ts). In a sandbox that is the guest's own data dir: it is not one of the host folders the sandbox mounts (packages/codev-sandbox/src/mounts.ts), so the work happens where the user's editor cannot see it, and a reset re-clones the guest disk and takes it along. Meanwhile the chip says "Local" under a monitor icon, which is the opposite of true. Three storage designs were costed before choosing to hide it — a checkout inside the mounted repo, a dedicated hidden worktree mount per sandbox, or leaving it on the guest disk — and none of them make a guest worktree usable from the host, because a worktree's .git file holds an absolute path back to <repo>/.git/worktrees/<name> and that path is /mnt/<name> in the guest and the real path on the host. Note also that git worktree add runs with cwd = the project worktree, i.e. the mounted host repo, so it registers the worktree in the user's real .git whatever we do with the checkout. | Keep the gate. If Work should offer workspaces later, the storage question has to be answered first — where the checkout lives, and what a reset does to it — not just the label; re-read the reasoning column before reopening it. Read the side from sideForServer(serverSDK().server), matching the project picker row above, so a draft is judged by its own server rather than by whichever side the sidebar is showing. If upstream changes what showWorkspaceBar gates, keep the third condition rather than reverting to the two-term memo. | | New-session project picker on Work: packages/app/src/pages/session/composer/session-composer-controls.ts, packages/app/src/components/{prompt-project-selector,app-sidebar}.tsx | Extends the Code/Work split row above to the composer's project picker. createPromptProjectControls indexed server.list in four places — the grouped project list, the two server lookups behind select/add, and the server field the controls hand back — so a draft on the Work side listed the local machine's projects under a "Local Server" heading beside the sandbox's own, and picking one moved the session onto that server. All four read a sideServers() memo instead: serversForSide(server.list, sideForServer(projectServer())), from the fork-only src/workspace/side.ts. The side comes from the draft's own server (serverSDK().server) rather than useWorkspaceSide(), so a draft always offers what it can actually run on whichever side happens to be showing. Nothing else changes: with one server on the side the picker takes the same single-server branch Code has always taken (layout.projects.list(), or the draft's server ctx for a draft route), which drops the group headings and the "Add project" submenu the way it does for a lone local server. Add project on Work is a mount: addProject opens the fork-only DialogSandboxMounts for that sandbox instead of pickDirectory, through the fork-only src/sandbox/mount-dialog.tsx — upstream's picker browses the server it is pointed at, which for a sandbox is the guest's own filesystem. PromptProjectControls gains an optional mount flag and prompt-project-selector.tsx reads it in two label getters (labels.add, labels.new) so the item says "Mount project" (sandbox.server.mounts, the string the Work sidebar's button already uses); nothing else in that component changes. app-sidebar.tsx's Work branch and the fork-only mount-prompt.tsx both route through the same hook rather than repeating the lazy import. | The two sides exist so that "am I on my machine or in a sandbox?" is a property of where you are. A picker that offers both breaks that in the one place it matters most — the moment a session is created — and the failure is silent: the session opens on the local machine with no sandbox isolation, from a screen the user reached through the Work tab. | Keep the filter and take upstream's surrounding changes. If upstream adds a fifth server.list read here, filter it too. Read the side from sideForServer(projectServer()), not useWorkspaceSide() — a draft route mounts its own server (ResolvedDraftRoute in app.tsx), and the sidebar's side is not guaranteed to agree with it. The sideServers().length <= 1 branch must stay a length test on the side list, not on server.list: on a machine with one sandbox and one local server the old test sent Work down the multi-server path. | | packages/app/src/context/server.tsx | createServerProjects.close() wraps its two store writes — remove(directory) on projects, then the recentlyClosed record — in batch() (already imported). The recently-closed list is computed before the batch so the pair is one update. | The two writes are separate store keys and Solid flushes effects after each one, so between them a project is in neither list. Nothing upstream observes that gap, but the fork's src/sandbox/projects.ts seeding effect reconciles against both lists, saw a mounted folder that was neither open nor closed, and opened it straight back up — closing a Work folder was undone the instant it happened. | Keep the batch. On conflict take upstream's close() and re-wrap the two writes. Any future write pair that spans projects and recentlyClosed needs the same treatment; regression cover is packages/app/test-browser/sandbox-projects.test.ts ("closing a mounted folder sticks when the guest knows the project"). |

| WSL auto-install from the sandbox dialogs: packages/app/src/i18n/{en,es}.ts, packages/desktop/src/preload/{index,types}.ts | i18n gains the sandbox.wsl.* family (install action/retry, the permission-and-size hint, download progress with and without a content-length, the elevated step, restart-required, the restart-Windows action with its confirm step, and the failure line). preload exposes five channels on the existing api.sandboxes object — sandbox-wsl-install{,-cancel,-state}, a sandbox-wsl-install-subscribe/-event pair, and sandbox-wsl-restart (runs shutdown.exe /r /t 0 for the restart a restart-required install asks for; the renderer confirms first) — and re-exports WslInstallState. All the logic is fork-only: src/main/sandbox/wsl-install.ts (the state machine), src/main/wsl/install-bundle.ts (manifest-driven per-arch MSI acquisition), src/app/sandbox/wsl-install.tsx (the shared UI both create-sandbox dialogs render in place of their form). | A Windows machine without WSL could not create a sandbox and was told to go install it by hand — while the app already shipped an installer for the Code side's WSL servers. The sandbox needs only the runtime (it imports its own rootfs and mounts its own server), so this reuses installWslRuntimeDualPath and skips the distro and CoDev-in-distro steps entirely. Its own channel set rather than a second sandbox-event type because this is platform state that exists before any sandbox does, and it lives in main rather than in the dialog because a ~250MB download on a whitelisted-proxy link must survive the dialog closing. | Keep the key family and the five channels; take upstream's surrounding changes. The channel names are preload/renderer contract — renaming one side alone breaks the dialogs silently. If upstream reworks api.sandboxes, keep the install methods on it rather than promoting them to a new top-level namespace: the renderer reaches them through platform.sandboxes. | | Update checking and download controls: packages/app/src/updater.ts, packages/app/src/components/{updater-action.ts,settings-general.tsx,settings-v2/general.tsx}, packages/app/src/pages/layout.tsx, packages/app/src/i18n/{en,es,vi}.ts, packages/desktop/src/main/{updater.ts,updater-controller.ts,index.ts,ipc.ts}, packages/desktop/src/preload/{index,types}.ts, packages/desktop/src/renderer/index.tsx | UpdaterState gains available (found, not fetched) and the shared UpdaterPrefs — autoCheck defaulting true, autoDownload defaulting false. The controller takes initialPrefs/savePrefs, stops a check at available when autoDownload is off, gains download() for the consent step and downloadAndInstall() for "Update now" (joins a download already in flight rather than starting a second, re-checks its way out of the version-less error state so the dialog's retry is not inert, otherwise fetches, then installs), gains getPrefs/setPrefs/subscribePrefs, skips the launch check when autoCheck is off, and re-asks the feed from available rather than returning the known version (a newer version replaces it, a withdrawn one lands on up-to-date, and a re-check that fails keeps the known version instead of withdrawing it as error). download/install moved out of the returned object literal into consts above it so downloadAndInstall can call them. main/updater.ts reads and writes the prefs in the existing codev.updater store under a prefs key; main/index.ts's ten-minute timer consults getPrefs().autoCheck at fire time; ipc.ts adds updater-download, updater-download-install and updater-set-prefs and registers a second listener on the existing updater-subscribe so prefs broadcast to every window; preload and renderer/index.tsx expose download, downloadAndInstall, setPrefs, subscribePrefs. The two pref switches are SettingsRowV2 rows in settings-v2/general.tsx — the screen every settings entry point actually resolves to — guarded by platform.updater, since the Updates section is not desktop-gated and the web build must not render them; the matching SettingsRow pair in the legacy settings-general.tsx is kept in step but is unreachable (see the retired-legacy-layout row). The legacy layout's toast offers Download when the update is not on disk, and the titlebar pill deliberately stays ready-only — it installs, and an undownloaded update cannot be installed. i18n drops the dead settings.updates.row.startup.* keys (translated, never rendered) for settings.updates.row.{autoCheck,autoDownload}.*, settings.updates.action.download and toast.update.description.available. The downloading state additionally carries a whole-number percent: the controller gains reportDownloadProgress(percent) (floored, no-op unless currently downloading and the whole percent moved — the backend reports every chunk and each transition is a log line plus an IPC send to every window), and main/updater.ts feeds it from autoUpdater.on("download-progress"). The fork-only update dialog (dialog-update.tsx) is the consent flow itself: on the launch check it announces the found version and asks Update now / Later, once per version per app run, so a newer release the periodic re-check finds after a Later is announced again; Update now calls downloadAndInstall() once and the dialog reports the download percent and then the install while the app restarts itself; Later does not close the dialog but swaps its body for where to find the update again and its footer for a single OK. While the update is running the neutral slot becomes Close, since the download outlives the dialog. i18n adds toast.update.description.{downloading,installing,later}, toast.update.action.{updateNow,later} and common.ok. | Upstream checks and downloads unconditionally. The fleet is on metered, whitelist-only links where a several-hundred-megabyte installer arriving unannounced is a real cost, and the app already shipped the copy for a setting it never built. autoDownload ships off because that is the only ordering in which "do you want to update now?" can be asked: with it on, the launch check has already spent the bytes by the time any dialog opens. Prefs live in the main process because that is where the timer runs; putting them in the renderer's settings store would have meant main parsing the renderer's persisted format. The switches originally shipped in settings-general.tsx alone, which nothing in this fork can open, so both settings were unchangeable and the available state they gate was dead in every build — the pair reaching settings-v2 is what makes the rest of this row real rather than nominal. | Keep the available state and the prefs plumbing together — the state is unreachable without autoDownload, and the setting is meaningless without the state. Take upstream's other changes to these files. If upstream reworks createUpdaterController, re-apply the two gates (autoCheck in start(), autoDownload in check()), the available re-check and downloadAndInstall rather than the whole diff; all four are covered by updater-controller.test.ts. Do not reinstate an early return from check() while available — it made a postponed update hide every release after it until relaunch. autoDownload must stay false and autoCheck true: the update prompt announces what the launch check found and asks before spending anything, which needs the check to run and then stop at available. Do not "restore" both to true to match upstream — that silently turns the prompt back into a report of a download already underway. Any new updater switch goes in settings-v2/general.tsx; adding one only to settings-general.tsx ships nothing. | | packages/app/src/context/global.tsx | Two lines: imports seedSandboxProjects from the fork-only packages/app/src/sandbox/projects.ts and calls it with server.list + ensureServerCtx, beside the memo that already creates a ctx per connection. All the logic lives in the fork-only module. | A sandbox exposes exactly one directory (/workspace on vz/bwrap, the wslpath translation on WSL) and the guest registers it as its own project, but the app's per-server project list starts empty and nothing ever fills it. With no project, every "New session" entry point — home-model.openNewSession, the titlebar's openNewTab fallback chain — finds nothing for that server and falls back to another one, so the sandbox looks selected while the session runs on the local server. The seed reads sync.data.path.directory, which every server ctx already fetches, so it stays backend-agnostic. The same module also names that project after the sandbox's host folder (via client.project.update, the call behind rename-project), because the guest sees only its mount point and every sandbox would otherwise read "workspace" in the sidebar, tabs and palette. | Keep both lines; take upstream's other changes to the file. The call must stay inside the Global context init (it needs ensureServerCtx) — SandboxesProvider is mounted above GlobalProvider in app.tsx, so it cannot live there. |

| packages/app/src/pages/layout/project-avatar-state.ts | Both ensureServerState lookups are gated on connection(): unread before notification.ensureServerState, and the permissionState memo before permission.ensureServerState (which also moves below connection, and its consumer in hasPermissions handles the undefined state). Matches the guard hasPermissions/hasQuestions/loading already apply to sync(). | ensureServerState throws on a key absent from the connection list, and the throw propagates out of a render-time memo, killing the whole renderer. Sidecar connections are listed only while healthy — readySandboxConnections/readyWslConnections publish a sandbox or WSL server only in the ready state — so any removal, restart, or crash of one drops its key while its session tabs are still mounted. Reproduced by removing a sandbox that had an open session tab: Error: Notification server not found: sandbox:<name>. The permission lookup is the sharper of the two because createMemo computes eagerly: for a tab mounted after its server disappeared it throws during construction, before any downstream guard runs — Error: Permission server not found: sandbox:<name>, reproduced by deleting a sandbox from Settings while the Work side was open. | Keep the guard; take upstream's other changes to the hook. This is an upstream bug (WSL servers hit the identical path), so if upstream fixes it their way, drop ours in favour of theirs. | | packages/app/src/components/titlebar.tsx | The effect that opens a tab for the session the route points at now skips servers that are not in global.servers.list(). | The tab store prunes tabs whose server left the list (context/tabs.tsx), and this effect adds one back for whatever the route names — so a route pointing at a server that is momentarily absent puts the two in a loop: prune, re-add, prune, at about a thousand rounds a second. The renderer never yields, so it also never processes the IPC event that would bring the server back, and the window stays wedged until it is killed. Sidecar connections are published only while healthy (readySandboxConnections/readyWslConnections), so any restart drops one: reproduced by adding a folder to a sandbox while one of its sessions was open, which restarts the guest to apply the mount. The route is deliberately left alone — the tab returns on its own when the server comes back. This is an upstream bug (WSL servers hit the identical path). | Keep the guard; take upstream's other changes to the effect. If upstream fixes it their way, drop ours in favour of theirs. Do not "simplify" the guard away — an unguarded add against the pruner is the hang. | | Tabs that outlive a sandbox restart: packages/app/src/context/{server,tabs}.tsx, packages/app/src/app.tsx, packages/app/src/i18n/{en,es}.ts, packages/desktop/src/renderer/index.tsx | server.tsx takes a retainedServers prop and exposes it as retained — servers that exist but are not connected; tabs.tsx reconciles both of its prune effects (open tabs and the closed-tab stack) against list plus retained through a shared knownServers(); app.tsx threads retainedServers from AppInterface into ServerProvider, and both server-scoped routes — TargetServerRoute and ResolvedDraftRoute — render the fork-only packages/app/src/sandbox/server-away.tsx (a LoaderV2 over the copy, since the state always ends by itself) instead of mounting their providers when the route's server is retained but absent; i18n gains session.error.disconnected{,.description}, worded around "this tab" rather than "this session" because the draft route shares the screen; retained is read by three fork-only consumers besides the routes — pages/home-model.tsx keeps the sidebar's selection on an away server and renders its projects/pinned/recents empty rather than the current server's, and workspace/tabs.tsx keeps the tab strip's folder scope — so a restart no longer swaps the sidebar to the local machine and back; renderer/index.tsx passes retainedSandboxServers(...) (fork-only, in renderer/sandbox/connections.ts) — every sandbox that exists, whatever its runtime state. | A sandbox is published as a connection only while it is ready, so applying a mount — which restarts the guest — takes it out of server.list for a second or two. The tab pruner read that as "this server is gone" and deleted every tab belonging to it, discarding each tab's memory: the typed prompt, terminal and scroll state. Retention draws the line where the user does — the sandbox still exists, so its tabs do — and explicit removal is unaffected because dialog-select-server already calls tabs.removeServer(key) and the sandbox then leaves retained as well. The route guard is the other half: a retained server has no connection, and mounting ServerSDKProvider/ServerSyncProvider with undefined sends the session's requests to whichever server is selected instead, which answered "this session cannot be found" about a healthy session and bounced the route home through a burst of ~250 navigations. WSL servers are deliberately not retained — same latent problem, but the WSL vertical has no equivalent restart in normal use. | Keep all five edits together — retention without the route guards just moves the confusing state, and the guards without retention are unreachable. Both routes need the guard: they are the two places a tab can point at a server, and the draft one is worse without it — a draft resolves to whichever server is selected, so the prompt would be sent to the local machine rather than the sandbox the tab belongs to. Take upstream's other changes to these files. retained must stay a superset relationship with list (a server can be in both mid-transition); if upstream reworks the pruner, re-apply the union. The web build passes no retainedServers, so retained is empty there and every one of these paths collapses to upstream behaviour. | | script/publish.ts | The if (Script.release) block calling packages/desktop/scripts/finalize-latest-{json,yml}.ts removed, replaced by a comment. | Both finalizers existed to assemble update metadata into a GitHub release, which the fork no longer publishes to. electron-builder already writes a correct per-platform latest*.yml next to each installer, and the fork builds one arch per platform (macOS is universal — see the universal-builds row), so there is nothing left to merge. | Keep removed. If upstream adds further desktop release steps here, drop them unless they target the bucket. | | packages/desktop/scripts/finalize-latest-json.ts, packages/desktop/scripts/finalize-latest-yml.ts | Deleted. | Both gh release upload into a GitHub release the fork does not use. finalize-latest-yml.ts merged per-arch latest*.yml from an upstream build matrix the fork does not run; finalize-latest-json.ts additionally built a Tauri-style latest.json signed with @tauri-apps/cli signer that nothing in the Electron updater path ever reads, and had gone stale besides — it hardcoded opencode-desktop-mac-{x64,arm64}.app.tar.gz, an artifact name and format this fork never produces. Leaving them in place is a trap: they look like the release path and upload to the wrong place. | Keep deleted (git rm the upstream side of modify/delete conflicts). If a future build matrix produces two latest.yml files for one platform (Windows x64 + arm64), write a bucket-targeted merge step rather than restoring these. | | packages/desktop/scripts/prebuild.ts | Defaults CODEV_CHANNEL to dev (??=, so CI's explicit value wins) before the node-sidecar build — same reason as the predev row: packages/script otherwise derives the channel from git branch --show-current, and the channel names the session database. Before the node-sidecar build, runs bun install --cpu=* from the workspace root so every CPU's optionalDependencies for the host OS are present. On macOS, after the node-sidecar build, runs bun script/build-vzhelper.ts --universal in packages/codev-sandbox so dist/codev-vmm exists (universal) before electron-builder copies it via mac.extraResources. | --cpu=*: bun installs optional platform packages for the host CPU only, so an x64 Windows host never gets @lydell/node-pty-win32-arm64 or @parcel/watcher-win32-arm64 into packages/desktop/node_modules, electron-builder logs them under "missing optional dependencies" and packages an --arm64 installer that ships only the x64 prebuilds — and both wrappers resolve …-${process.platform}-${process.arch} at run time, so on an arm64 machine the terminal backend fails to load. The install is additive (host packages stay), leaves bun.lock unchanged, and the Universal-macOS row above already relies on all platform packages being present in both halves — this makes that reproducible instead of a property of whichever machine built the release. The sandbox vz backend spawns the helper from process.resourcesPath/codev-vmm in packaged builds; universal keeps one already-fat Mach-O valid for --arm64, --x64, and --universal packaging (an identical thin binary in both halves would trip @electron/universal's identical-file check). | Keep the bun install --cpu=* line and the darwin-gated build line; take upstream's other steps. Both must run before packaging — prebuild is the hook package:* runs. Verify an --arm64 package rather than the build log: dist/win-arm64-unpacked/resources/app.asar.unpacked/node_modules/@lydell/ must contain node-pty-win32-arm64, and electron-builder's "missing optional dependencies" line must not list a win32-arm64 package. | | packages/desktop/scripts/predev.ts | Runs bun run install-electron before the existing icon copy and node-sidecar build; defaults CODEV_CHANNEL to dev (??=, an explicitly set value wins) before those so the node-sidecar build bakes a stable channel — with it unset, packages/script falls back to git branch --show-current, and core's Database.path() suffixes the session database with the channel (opencode-<channel>.db), which stranded every branch's dev-run sessions in their own file; after them, on macOS only, runs bun script/build-vzhelper.ts in packages/codev-sandbox (host arch, non-fatal with a warning when no Swift toolchain is present). | bun dev:desktop was dead on a fresh clone: Electron 42 removed the postinstall hook that downloaded the platform binary and exposes it as an install-electron bin the consumer must call (electron 41's manifest still had "postinstall": "node install.js"; 42.3.3's has no scripts field at all), so bun install leaves node_modules/electron with no dist/ and electron-vite dev aborts with Error: Electron uninstall from getElectronPath. predev is the right hook rather than the root postinstall: only someone actually launching the desktop app pays the ~100 MB download, and install.js short-circuits once dist/version and path.txt match, so repeat runs cost nothing. Not added to prebuild/package — electron-vite build never spawns Electron, and electron-builder fetches its own copy through @electron/get. The vzhelper build fixes the same fresh-clone class of failure for the Work side: the vz provider's dev helperPath points at codev-sandbox/native/vzhelper/dist/codev-vmm, which only prebuild (packaging) built, so every dev run answered the sandbox dialogs with "sandbox VM helper is not installed". Incremental (~1.5s when up to date). | Keep our installer line at the top and the vzhelper block at the bottom; take upstream's other steps. Drop the installer line if upstream adds its own binary-install step or pins Electron back below 42; the vzhelper block is tied to the fork-only sandbox feature and stays as long as it does. | | packages/ui/src/assets/favicon/ (favicon-v3.svg, favicon-v3.ico, site.webmanifest, favicon-96x96-v3.png, apple-touch-icon-v3.png, web-app-manifest-192/512.png) | New codev mark: the blocky "C" in brand red #ee0033 on a white tile, generated from the same geometry as the app icon by packages/desktop/scripts/generate-icons.ts (4x5 grid, one-unit stroke, unit = 120/1024, corner radius 229/1024); favicon-v3.svg is the hand-written 512-unit source of that geometry. The tab and .ico icons keep the rounded tile so they read as an app icon; apple-touch-icon-v3.png and the two maskable web-app-manifest-* icons are square and full-bleed, because iOS and PWA launchers apply their own mask and a pre-rounded icon leaves transparent corners that render black. Manifest name "CoDev Code"/"codev"; its theme_color/background_color stay #080808 — those describe the PWA's chrome and splash, not the icon. Was #00cdcd on a dark tile until this row was rewritten: PR #10 pinned the favicon to the then-cyan TUI banner accent, that banner later moved to the navy/red wordmark, and the favicon was left behind as the only cyan in the tree — visibly so on desktop, where the dock icon was red while the window and notification icons were cyan. | Every surface that draws the mark should draw the same mark: the dock icon, the tab, notifications, the codev --mini badge and the landing page icon are now one geometry in one red. Cyan on #131010 was the higher-contrast tile at 16px (~9.4:1 versus red's ~4.2:1) — a real cost, accepted so the brand reads consistently. | Keep our assets (binary: take ours wholesale). Regenerate with the script rather than hand-editing, and edit icons/icon.svg + favicon-v3.svg together if the geometry ever changes. | | AGENTS.md | Default-branch note: dev → main (single merged line; upstream has two lines saying local main may not exist); added fork bullets requiring every upstream-file change to get a DIVERGENCES.md row (and matching en + es doc updates) in the same PR, and pointing agents at this file's Divergence policy (prefer config/plugins, then packages/codev-*, upstream edits last) and Syncing with upstream sections | The fork's default branch is main (see the workflow-triggers row); agents kept basing diffs on a nonexistent dev. Agents edit upstream files without recording the divergence — and don't apply the placement policy or sync runbook — unless the instructions file says to. | Keep our lines; take upstream's other guidance. | | README.md | Replaced with fork README | Fork identity, sync + divergence policy. | Keep ours entirely. | | CONTRIBUTING.md, SECURITY.md | Product-brand prose "OpenCode" → "CoDev Code"; built-CLI command examples opencode … → codev … | Docs people actually read should name the fork's product. Upstream issue/advisory links, packages/opencode paths, and build-output paths stay upstream; env-var mentions follow the env-var rename row. | Keep our strings; take upstream's structural changes. | | SECURITY.md (sandbox section) | "No Sandbox" is scoped to the CLI and local desktop sessions; a new "Desktop sandbox (Work sessions)" section describes the fork-only packages/codev-sandbox guest (VM / WSL2 / bubblewrap, no network device, broker-only egress, seccomp allowlist on Linux) and puts escapes from it in scope; the out-of-scope row is reworded from "Sandbox escapes" to "Permission-system bypasses" | Upstream's statement that the product has no sandbox is false for this fork's desktop app, and a security policy that disclaims sandbox escapes while shipping one would turn real reports away. | Keep ours (both sections and the table row); take upstream's other changes to the file. | | CONTEXT.md | Product-brand prose "OpenCode" → "CoDev Code" (title, intro, and defined terms such as "CoDev Code Client" / "Embedded CoDev Code") | The V2 design glossary should name the fork's product; the renamed terms appear only in this file, so nothing desyncs. @opencode-ai/* package names stay upstream; env-var mentions follow the env-var rename row. | Keep our brand strings; take upstream's design changes (this file evolves with the V2 session core — merge content, re-apply the brand rename). | | STATS.md | Deleted | Upstream's download-stats log; meaningless for the fork. | Keep deleted. | | Test prose across packages/*/test, packages/opencode/test/lib | describe titles + comments naming the CLI command or product: opencode <cmd>/"opencode CLI"/"OpenCode" → codev …/"CoDev Code" (incl. help-snapshot keys/labels — regenerate the .snap, see the scriptName row). Functional names stay: @opencode-ai/*, ~/.opencode/bin install paths, provider id opencode, fixture APIs (opencode.run, OpencodeCli), auth method opencode-login, default username opencode, upstream issue refs, third-party names. Config-file and config-dir fixtures follow the rename rows (codev.json, .codev/). | Test names/comments are read by people; identifiers are load-bearing. | Keep our prose; take upstream's test-logic changes. | | README.*.md (21 translations) | Deleted | Upstream marketing translations, irrelevant to the fork. | Keep deleted (git rm the upstream side of modify/delete conflicts). | | screenshot-uk.png | Deleted | Orphaned README-translation asset; referenced by nothing. | Keep deleted. | | artifacts/ (whole directory) | Deleted | Upstream's Remotion marketing-video project (glm52-rise-video) with rendered MP4/PNG binaries committed; irrelevant to the fork and bloats every clone. | Keep deleted (git rm the upstream side of modify/delete conflicts); delete new upstream additions under artifacts/ too. | | packages/web/ except src/content/docs/*.mdx, plus packages/docs/ (whole directory) | Deleted — the Astro site scaffolding (astro.config.mjs, config.mjs, package.json, src/{components,pages,assets,styles,i18n,types}, public/, middleware.ts, content.config.ts), every translated docs directory including es/, src/content/i18n/, and the untouched Mintlify starter kit. The 36 English .mdx doc sources are kept — see the web-docs rebrand row. The sst.cloudflare.x.Astro("Web") block in infra/app.ts stays removed. | The site itself is never built or deployed by the fork — it also carried upstream's marketing lander and the /s/<id> share viewer — and the published docs live in the codev-landing-page repo (content/docs/code), which every in-app docs link points at. But deleting the doc sources made upstream doc changes invisible: the sync runbook's filter skips any path that existed at FROM and is absent from HEAD, so between v1.18.3 and v1.18.4 the fork silently dropped upstream's doc edits instead of conflicting on them. Keeping the English sources at their upstream paths restores that signal. Because nothing builds them, their Starlight component imports, config.mjs import and ../../assets/** image references are deliberately dangling. | Keep the scaffolding, the translated docs dirs and packages/docs/ deleted (git rm the upstream side of modify/delete conflicts); delete any new upstream file that lands under those paths, including any new locale directory. Keep the English .mdx sources — never delete them again to silence a conflict; a conflict there is the point. | | Web docs, repo-wide (36 English files under packages/web/src/content/docs/) | Product-brand prose "OpenCode" → "CoDev Code" and built-CLI examples opencode <cmd> → codev <cmd> (incl. bare opencode invocations); XDG data/cache/config path examples corrected to match Global.Path (app = "codev" in packages/core/src/global.ts): ~/.local/share/opencode→…/codev, ~/.cache/opencode→…/codev, $XDG_CONFIG_HOME/opencode/themes→…/codev/themes, and the %USERPROFILE%\… Windows forms. Install docs rewritten to the fork's real distribution (index.mdx, windows-wsl.mdx, gitlab.mdx CI): the fork ships only as the npm package codev-code (npm/bun/pnpm/yarn) with GitHub release binaries at quickbeard/codev-code — matches packages/opencode/src/installation/index.ts. Removed the channels the fork does not publish (upstream's curl … opencode.ai/install script, brew install anomalyco/tap/opencode, Arch pacman/AUR, Chocolatey, Scoop, Mise github:anomalyco/opencode, docker … ghcr.io/anomalyco/opencode) and the anomalyco/opencode/releases link. ide.mdx Manual Install rewritten from "search OpenCode in the Extension Marketplace" to building the .vsix from a checkout (cd sdks/vscode && bun install && bun run vsix, then code --install-extension) — the fork's extension has no Marketplace listing. NOT renamed (load-bearing / not ours): "OpenCode Zen" and "OpenCode Go" (+ their team refs and opencode.ai/zen/... URLs / opencode,opencode-go provider+model ids), every opencode.ai URL incl. the $schema values (config.json/tui.json/theme.json), @opencode-ai/* SDK/plugin packages, GitLab integration (third-party nagyv/gitlab-opencode component, its @opencode trigger, anomalyco/opencode repo refs; the GitHub integration is rebranded — see the github.mdx row), runtime defaults that still match code (opencode.local mDNS, .well-known/opencode, theme id/sound-pack opencode, default basic-auth username opencode), and third-party project names (opencode.nvim, opencode-helicone-session, awesome-opencode, etc.) These sources are not published from this repo — nothing builds them and the reader-facing copies live in codev-landing-page (content/docs/code/{en,vi}), converted to that site's MDX pipeline. They exist so an upstream doc change surfaces as a conflict here; port the substance across by hand, it cannot be merged. | The published docs should name the fork's product and its codev CLI, and copy-pasteable commands/paths must work against the fork (binary codev, npm package codev-code, XDG dir codev); documenting install channels the fork doesn't publish sends users to stock opencode. The kept items are external products, real URLs/packages/integration identifiers, or runtime values that must match unchanged code. | Keep our brand/CLI/path/install strings; take upstream's structural doc changes. Rebrand any new upstream opencode <cmd>/"OpenCode" prose and stale opencode XDG path, leaving the NOT-renamed list. If upstream re-adds brew/curl/choco/scoop/mise/docker install methods, drop them unless the fork actually publishes to that channel. | | packages/web/src/content/docs/github.mdx | GitHub-integration docs rebrand: trigger /opencode//oc → /codev, workflow examples .github/workflows/opencode*.yml → codev*.yml with uses: quickbeard/codev-code/github@main and GITHUB_TOKEN in env; the "Install the GitHub app" manual-setup step removed (GITHUB_TOKEN auth is the default; use_github_token/mentions documented, upstream App documented as the opt-out use_github_token: false path); share documented as defaulting to false; example permissions swap id-token: write for the contents/issues/pull-requests: write the token path needs. | Docs must match the rebranded CLI handler + action defaults (rows above); documenting upstream's /opencode trigger and GitHub App would mislead fork users. | Keep our triggers, action ref, auth model, and defaults; take upstream's structural doc changes. | | github/ (whole directory) | Deleted — action.yml was a composite action living in this repo, so uses: quickbeard/codev-code/github@main resolved for nobody outside it; its only job was installing the public codev-code npm package and running codev github run, which the documented workflow now does inline (see the github.handler.ts row). With the action gone the rest of the directory was orphaned: index.ts was a standalone duplicate of the githubRun logic that action.yml never invoked, and the package was not a member of the root workspaces list, so nothing built, tested, or imported it. README.md content now lives in the fork's docs at content/docs/code/{en,vi}/github.mdx in codev-landing-page | The fork's repo is private and owned by a user account, not an org, so GitHub cannot share an action in it with any other repository — every user who copied the documented workflow hit "repository not found" on their first run. The npm package is public, so inlining the steps is the only distribution path that works, and it leaves the whole directory dead. The fork has no GitHub App and its share service defaults off (config.ts row), so sharing defaults off here too | Keep deleted (git rm the upstream side of modify/delete conflicts); delete new upstream additions under github/ too. If this repo ever goes public and the action is worth restoring, restore action.yml only and re-point the docs. | | packages/opencode/src/cli/cmd/github.handler.ts | CLI side of the GitHub integration rebranded to match github/ (row above). githubRun: MENTIONS default /opencode,/oc → /codev, generated branches opencode/* → codev/*, comment footer [opencode session] → [codev session], visible log/prompt strings "opencode" → "codev", and USE_GITHUB_TOKEN defaults to true when unset (upstream returns false). githubInstall: no longer installs upstream's GitHub App (browser flow + api.opencode.ai install poll removed); scaffolds .github/workflows/codev.yml (was opencode.yml) triggering on /codev with contents/issues/pull-requests: write permissions, an npm install -g codev-code step and a codev github run step carrying GITHUB_TOKEN and MODEL in env — no uses:, see the github/ row; next-steps text says /codev summarize and links the fork's hosted docs at /docs/code/github (the github/ README it used to link is deleted). | The trigger phrase, branches, footer, and scaffolded workflow are user-facing. The fork has no GitHub App to install, so OIDC token exchange can never succeed here: USE_GITHUB_TOKEN must default true, or an unset variable sends users down the exchange path and fails asking for id-token: write. Upstream's false default was masked while the composite action passed the input explicitly; deleting the action exposed it. NOT renamed: AGENT_USERNAME opencode-agent[bot] (upstream App bot identity, used for reaction cleanup in the app-token path), api.opencode.ai exchange endpoints, the opencode-github-action OIDC audience, opencode.ai share URLs, and the social-cards URL. | Keep our trigger/branch/footer strings, the app-less install flow, the USE_GITHUB_TOKEN true default, and the actionless scaffold; take upstream's surrounding logic changes. If upstream adds new user-facing "opencode" strings or workflow scaffold changes, rebrand them the same way; never let a scaffold change reintroduce uses: quickbeard/codev-code/github@main. | | Project-repo links: packages/tui/src/component/error-component.tsx, SECURITY.md, packages/web/src/content/docs/{troubleshooting,agents}.mdx | "This project's repo" links github.com/anomalyco/opencode → github.com/quickbeard/codev-code: the TUI crash screen's report-bug URL (issues/new?template=bug-report.yml — the fork's template keeps upstream's field ids incl. opencode-version, so the prefill params still work) and its auto-generated "Reported automatically from the opencode crash screen" prose → "codev"; SECURITY.md's report-a-vulnerability advisories link | Crash reports, vulnerability reports, and contributions belong on the fork's repo, not upstream's. NOT repointed (deliberate upstream references): README attribution + CHANGELOG/DIVERGENCES upstream citations, upstream issue links in code comments (mcp/index.ts), model-persona prompt files, test fixtures (github-remote.test.ts, shared.test.ts), tree-sitter wasm download URLs, the disabled AUR/Homebrew section of script/publish.ts, packages/{cli,ui,http-recorder} manifest metadata (unpublished by the fork), console/stats/enterprise routes (not deployed by the fork), and desktop copy-metainfo.ts (desktop unshipped — revisit with the desktop bucket). | Keep our URLs and crash-screen prose; take upstream's surrounding changes. Repoint any new "report/contribute here" link; leave the NOT-repointed list. | | .github/TEAM_MEMBERS | Deleted | Upstream's team roster. The five workflows that read it (pr-standards, pr-management, triage, duplicate-issues, compliance-close) fetch it from a dev branch this fork doesn't have, so they are already inert here. | Keep deleted. | | packages/script/src/index.ts | Guard the .github/TEAM_MEMBERS read: fall back to an empty team list when the file is absent instead of throwing at module load | This module is imported by the publish path (publish.ts); the deleted TEAM_MEMBERS (row above) made bun run release crash with ENOENT. Script.team has no consumers in the fork. | Keep our exists() guard; take upstream's surrounding changes. | | .github/workflows/ — 22 upstream workflows deleted (beta, close-issues, close-prs, compliance-close, containers, deploy, docs-locale-sync, docs-update, duplicate-issues, generate, nix-hashes, notify-discord, opencode, pr-management, pr-standards, publish, publish-github-action, publish-vscode, release-github-action, review, stats, triage) | Deleted | All inert or upstream-only here: gated on anomalyco/opencode, publish/deploy to upstream infra, need upstream secrets (OPENCODE_APP_SECRET, OPENCODE_API_KEY, marketplace/Discord/PostHog tokens), trigger only on a dev branch the fork lacks, or read the deleted TEAM_MEMBERS. | Keep deleted; delete new upstream workflows too unless they're generic self-contained CI. | | .github/workflows/{typecheck,test,nix-eval,storybook}.yml | Triggers dev → main (incl. test.yml's concurrency-group ref check); runners blacksmith-* → ubuntu-latest/windows-latest; test.yml unit-test step wrapped in a 2-attempt retry loop (bun turbo test, re-run once on failure) with timeout-minutes 20 → 60; test.yml exerciser step gains timeout-minutes: 30; test.yml Windows unit job pre-installs ripgrep via choco; typecheck.yml runs bun turbo typecheck --concurrency=3 (was bun typecheck, default concurrency 10) | Fork's default branch is main; Blacksmith runners aren't installed here, so jobs queued forever. GitHub-hosted windows-latest needs well over 20 minutes for the spawn-heavy opencode suite, and several process-spawn/timing-sensitive tests (snapshot-tool-race, skill discovery, flock contention, ...) flake individually there; bun has no per-test retry (--only-failures doesn't persist across invocations), so one flake failed the whole job. The retry re-runs only the flaked package (turbo cache-hits the rest); 60 min covers one re-run of the ~27min opencode suite. A wedged exerciser run otherwise holds the job for GitHub's 6-hour ceiling. The file-search index is built by a background ripgrep walk with swallowed errors; Windows's first-use rg.exe download left it permanently empty, failing httpapi-file's search test every run. Turbo's default concurrency (10) runs enough parallel tsgo compilers (opencode alone peaks ~4.6 GB) to OOM the 16 GB ubuntu-latest runner — the job dies with a runner-shutdown signal, no type error; with 4 cores the cap costs little wall time. | Keep our branches + runners + retry loop + timeouts + typecheck concurrency cap; take upstream's other changes. | | packages/core/test/ripgrep.test.ts | 120_000 timeout on both it.live tests | First use downloads + extracts the ripgrep binary; GitHub-hosted Windows runners blow bun's 5s default. | Keep our timeouts; take upstream's test changes. | | packages/opencode/test/cli/run/run-process.test.ts, packages/opencode/test/cli/acp/lifecycle.test.ts, packages/opencode/test/server/httpapi-file.test.ts, packages/opencode/test/server/httpapi-sdk.test.ts, packages/opencode/test/effect/runner.test.ts, packages/opencode/test/session/prompt.test.ts, packages/opencode/test/project/instance-bootstrap.test.ts | Slow-runner headroom: unknown-model test timeoutMs/bound 15s → 30s (test timeout 60s); the two unknown-stream-finish codev run subprocess tests ("unknown stream finish preserves partial output…", "--format json records partial output for an unknown stream finish") timeoutMs 30s → 60s and test timeout 60s → 120s (pure-upstream spawn tests that pay codev run cold-start; neither asserts on duration, so headroom only); the three tool-executing codev run subprocess tests ("prints each completed text part in order around a tool continuation", "--format json preserves reasoning, tool, and continuation ordering", "rejects requested permissions by default and allows them with the dangerous flag") timeoutMs 30s (harness default) → 60s explicitly, test timeout 60s → 120s — 240s for the permissions test, which pays three sequential codev run spawns; these pay cold-start plus a real bash tool spawn and a continuation turn, and were timing out at exactly the 30s harness default on loaded runners; none asserts on duration, so headroom only; ACP stdin-EOF exit timeout 5s → 30s; file-search-index poll 5s → 120s with a 150s test timeout (the search-endpoints test pays ripgrep's first-use download on Windows runners, like ripgrep.test.ts); Runner fiber-coordination Effect.timeout guards 250ms/1s → 10s (pure hang-detectors on already-unblocked awaits; Windows runners starve the event loop past 250ms); prompt.test.ts's two sleep-based loop/shell tests ("loop waits while shell runs…", "shell completion resumes queued loop callers") moved from it.instance to the file's own unix helper so they skip on Windows like their unixNoLLMServer siblings (sleep isn't on Windows, so the shell fiber hangs to the budget — a genuine hang, not slowness); instance-bootstrap's first test 30s → 60s (pays instance cold-start); httpapi-sdk's serverPathParity helper passes an explicit 120s to it.live, covering every parity test in that file (30s harness default → 120s) — each drives a whole route family end to end and several shell out to git/ripgrep or probe the formatter and LSP; "matches generated SDK instance read routes" hit exactly the 30s default when the rest of the suite was competing for the machine (~5s unloaded, so headroom only). Applied in the helper rather than per call site to keep the upstream call sites byte-identical | Loaded GitHub-hosted runners spawn processes, fetch ripgrep, and build the lazy file index slower than upstream's Blacksmith runners; 250ms–30s budgets flake or fail outright, and Unix-shell tests can't run on Windows at all. Hang regressions are still caught (a hang exceeds the bound; polls return as soon as ready). | Keep our numbers and the unix gating; take upstream's test changes. | | packages/opencode/test/tool/external-directory.test.ts, packages/opencode/test/tool/read.test.ts | Windows path-variant tests keep the drive letter in the alt form (drop the .replace(/^[A-Za-z]:/, "") step) | A drive-less path is drive-relative: it resolves against the CWD's drive (D: on GitHub-hosted Windows) while the temp dir lives on C:, so the file genuinely isn't there. Slash + case variants still exercise normalization. | Keep our alt construction; take upstream's test changes. | | packages/opencode/test/tool/write.test.ts | "sets file permissions when writing sensitive data" asserts stats.mode & 0o777 equals 0o666 & ~process.umask() instead of a hardcoded 0o644 | The write tool never chmods — it calls fs.writeWithDirs, so the mode is just the 0o666 creation default masked by the process umask. 0o644 silently encodes the umask 022 that CI runners happen to use, and the test fails with 0o664 on any developer machine running the equally common umask 002. The assertion still pins the tool to the platform default (a stray chmod would break it). | Keep our umask-derived expectation; take upstream's test changes. If upstream ever makes the tool chmod explicitly, drop this row and assert the explicit mode. | | packages/opencode/test/provider/provider.test.ts, packages/opencode/test/provider/transform.test.ts | Appended two fork-only blocks at the end of each file: an it.instance ("declared image modality makes a custom-provider model attachment capable") asserting a config model with no modalities resolves attachment/input.image to false while one carrying the @codev/gateway block resolves both true, and a describe ("ProviderTransform.message - image input on a custom provider") asserting unsupportedParts passes image/file parts through when the model declares image input and swaps them for the ERROR: Cannot read … note when it doesn't. No upstream test changed. | These pin the two upstream code paths the fork's config writer depends on (provider.ts modality→capability resolution, transform.ts part filtering); upstream leaves both untested, so a refactor there would silently re-break gateway image attachments. | Keep our appended blocks; take upstream's test changes. Append-only, so conflicts should be end-of-file context alone. | | packages/opencode/test/server/httpapi-exercise/environment.ts | Exercise config/data dirs config/opencode,data/opencode → …/codev; seeds codev.json with {"share":"manual"} | Follows the global.ts app-dir rename (row above); the fork's share: "disabled" default (config.ts row) would 500 the share/unshare scenarios, so the isolated environment opts back in (network stays stubbed by CODEV_DISABLE_SHARE). Seed file follows the config-file rename row (global config.json is no longer read). | Keep our dir names + seeded config; take upstream's surrounding changes. | | Skill Stack HTTP routes: packages/protocol/src/api.ts, packages/server/src/handlers.ts, packages/server/package.json, packages/schema/src/index.ts | api.ts imports and .add(SkillHubGroup.middleware(locationMiddleware)) (last group; src/groups/skillhub.ts is fork-only); handlers.ts merges SkillHubHandler (src/handlers/skillhub.ts, fork-only); server package.json adds "@codev/skillhub": "workspace:*"; schema index.ts exports SkillStack and SkillHub (src/skill-stack.ts, src/skill-hub.ts, fork-only). | The v2 group server.skillhub (stacks: GET /api/skillhub/stack, POST …/:slug/prepare, POST …/install, DELETE …/staging/:stagingID; skills: GET /api/skillhub/skill, GET …/:idOrName, POST …/:id/install) puts all CoDev SkillHub traffic behind the server, so the TUI (which reaches the hub only through the SDK) and the desktop app share one implementation and installs land on the host that runs the agent. The dependency direction stays Schema → Protocol → Server; the server takes the wire types through the protocol group's re-export rather than a direct schema dependency, matching the other handlers. | Keep the import + .add line, the handler merge, the dependency, and the schema export; take upstream's surrounding changes. | | Generated clients: packages/client/src/contract.ts, packages/client/test/promise.test.ts, packages/client/src/generated/{client,types}.ts, packages/client/src/generated-effect/client.ts, packages/sdk/js/src/v2/gen/{sdk,types}.gen.ts | contract.ts maps "server.skillhub": "skillhub" and names its endpoints listStacks/prepareStack/installStack/discardStack and listSkills/getSkill/installSkill; promise.test.ts expects "skillhub" last in the group list; the generated trees carry the group. | The fork-only protocol group must reach both SDKs; the endpoint names are overridden because the codegen's default (last id segment) would give list/prepare/install, which collide once single-skill routes join the group. | Keep our contract.ts entries and the test expectation; never hand-merge the generated trees — run bun run generate in packages/client and ./packages/sdk/js/script/build.ts after resolving the protocol. | | packages/opencode/test/server/httpapi-exercise/index.ts | Imports and calls installFakeSkillHub() from the fork-only skillhub-fake.ts before the scenario list, and adds the v2.skillhub.* scenarios (four stack, three skill) after v2.skill.list. | CI runs the exerciser with --fail-on-missing --fail-on-skip, so every fork route needs a real passing scenario, and none may reach the live hub: the fake swaps in @codev/skillhub's injectable transport (the same seam the desktop uses for Chromium's net.fetch), so the handler downloads, parses, plans, and stages a fixture stack exactly as it would against the hub. | Keep the import/call and the four scenarios; take upstream's surrounding scenario changes. | | packages/tui/src/component/prompt/index.tsx, packages/tui/src/config/keybind.ts | prompt/index.tsx imports the fork-only DialogStack (component/dialog-stack.tsx) and registers the prompt.stacks command (/stacks, category Prompt) directly after prompt.skills; keybind.ts adds prompt_stacks: keybind("none", …) beside prompt_skills and maps it to prompt.stacks. | The Skill Stack browser is the TUI's only way to reach the CoDev SkillHub (the hub is CORS-closed and the TUI reaches it through the server's fork-only v2.skillhub routes); it sits next to /skills because that is where a user looks for skills. Unbound by default like prompt_skills. | Keep the import, the command entry, and the two keybind lines; take upstream's surrounding command/keybind changes. | | sdks/vscode/package.json, sdks/vscode/src/extension.ts, sdks/vscode/README.md | name opencode → codev-code (the extension ID is publisher.name); displayName "opencode" → "CoDev Code"; publisher sst-dev → CoDev (PLACEHOLDER — no such Marketplace publisher is registered; the mixed-case CoDev also isn't a valid Marketplace publisher id, which must be lowercase — fine for the sideloaded .vsix the fork ships, but replace with the real fork-owned lowercase publisher id before any vsce publish, and keep it in sync with the CLI auto-install target); description "opencode for VS Code" → "CoDev Code for VS Code"; icon images/icon.png is a fork-local generated PNG of the CoDev "c" badge (brand red #ee0033 — the "dev" half of the wordmark, see packages/tui/src/logo.ts — on #0a0a0a), with images/icon.svg as its source of record — replacing the earlier symlink to the shared packages/identity bracket mark; the two per-theme command icon objects ({light,dark} → images/button-{dark,light}.svg) collapse to a single "images/button.svg" string and extension.ts's terminal iconPath object collapses to a single Uri, both pointing at a fork-local transparent brand-red #ee0033 "c" glyph that reads on light+dark themes (replacing the two packages/identity bracket-mark symlinks, now deleted); repository.url → github.com/quickbeard/codev-code; command IDs opencode.* → codev.* across contributes.commands/menus/keybindings and the registerCommand calls; command/keybinding titles "Open opencode…"/"Run opencode"/"opencode: Insert At-Mentioned" → "Open CoDev Code…"/"Run CoDev Code"/"CoDev Code: Insert At-Mentioned" (keys unchanged); TERMINAL_NAME opencode → codev and the terminal launches codev --port <port>; scripts gain "vsix" (vsce package --no-dependencies --skip-license -o dist/) with @vscode/vsce added as a pinned devDependency; README body rebranded and trimmed to the details-panel essentials — CoDev Code intro linking the fork repo, npm install -g codev-code prerequisite, and feature prose only; upstream's Support and Development/Making-Changes sections are dropped (they're contributor-facing, not end-user extension description) | The fork ships this extension as an installable .vsix, so every user-visible surface — palette titles, command IDs, terminal name, launched binary, details-panel README — must say codev, and the launched binary must be the fork's codev CLI. Distinct codev.* IDs also avoid clashing with upstream's extension if both are installed. --no-dependencies because the bundle is self-contained (no runtime deps, bun-managed lockfile); --skip-license because sdks/vscode has no LICENSE file and vsce aborts without it. | Keep our name + displayName + publisher + repository + description, command IDs + titles, TERMINAL_NAME, the codev --port launch line, the vsix script + @vscode/vsce devDependency, and the trimmed README (branding + dropped Support/Development sections); take upstream's extension-logic and manifest-structure changes. | | packages/opencode/src/ide/index.ts | IDE auto-install target sst-dev.opencode → CoDev.codev-code (publisher.name from sdks/vscode/package.json) | The CLI must install the fork's extension ID (publisher.name from sdks/vscode/package.json), not upstream's Marketplace listing. Ide.install currently has no runtime caller (only its test imports the module), and the install only resolves once the extension is actually published — until then the local .vsix is the supported install path. | Keep our extension ID (and re-sync it if the registered publisher id changes); take upstream's install/detection logic changes. | | Env-var rename, repo-wide (~230 files): both flag registries (packages/core/src/flag/flag.ts, packages/opencode/src/effect/runtime-flags.ts), every process.env/Bun.env read+set site across packages/*, sdks/vscode, infra/enterprise.ts, nix/opencode.nix, turbo.json, .github/workflows/test.yml, test preloads/fixtures/snapshots, docs (en + es), SECURITY.md, specs | OPENCODE_* env vars → CODEV_* (hard cutover, no legacy fallback), including the bundler define identifiers (CODEV_VERSION, CODEV_CHANNEL, CODEV_MODELS_DEV, CODEV_LIBC, CODEV_CLI_NAME, CODEV_WORKER_PATH), VITE_OPENCODE_* → VITE_CODEV_*, import.meta.env.OPENCODE_CHANNEL → CODEV_CHANNEL, the VSCode extension's OPENCODE_CALLER/_EXTENSION_OPENCODE_PORT, and the bare OPENCODE=1 marker → CODEV=1 | The fork's env surface uses the fork's name (reverses the earlier "internal names stay upstream" decision for env vars). NOT renamed: __OPENCODE_PHOTON_WASM_PATH and __OPENCODE__ globals (not env vars; the photon name is baked into patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch), patches/install-korean-ime-fix.sh script-local shell vars, upstream secret names in workflow-deletion notes, lowercase identifiers (@opencode-ai/*, URLs, provider id) | Keep our names; rename any new upstream OPENCODE_* env var (or define/VITE_ name) to CODEV_* when syncing. | | Locale dicts in packages/{ui,app}/src/i18n/, packages/desktop/src/renderer/i18n/, packages/console/app/src/i18n/, packages/stats/app/src/i18n/, packages/web/src/content/i18n/; every translated docs dir in packages/web/src/content/docs/ | Deleted every locale except en/es (incl. packages/app/src/i18n/parity.test.ts) | Fork ships English + Spanish, plus fork-authored Vietnamese (vi.ts dicts are new fork files — see the Vietnamese row below); every other upstream locale stays deleted. | Keep deleted (git rm the upstream side of modify/delete conflicts); also delete any locale files upstream adds — except a vi locale, which the fork owns (prefer our translations and reconcile key-by-key). | | packages/app/src/context/language.tsx, packages/app/src/entry.tsx, packages/desktop/src/renderer/i18n/index.ts, packages/console/app/src/i18n/index.ts, packages/console/app/src/lib/language.ts, packages/console/app/src/component/footer.tsx, packages/stats/app/src/i18n.ts, packages/enterprise/src/app.tsx, packages/app/e2e/regression/session-timeline-locale-projection.spec.ts | Locale registries trimmed to en + es, then extended with fork-added vi: Locale types, LOCALES/LABEL/TAG/DOCS maps (console maps vi to the published /docs/vi segment), dict loaders, accept-language matchers; enterprise/entry zh special cases now use es (both also match vi); console footer always links Discord (Feishu was zh-only); e2e locale-projection spec tests es ("Explorado") instead of de/ar | Fork ships English + Spanish + fork-authored Vietnamese. | Keep our locale lists (including vi); take upstream's structural changes, re-trim any new upstream locale entries, and re-apply the vi entries alongside them. | | packages/app/src/i18n/{en,es}.ts (language.vi label), script/translate-app.ts, script/translate-app.test.ts | Added Vietnamese (vi) as a fork-only third locale: autonym label "language.vi": "Tiếng Việt" inserted into the en/es app dicts' language list; vi/"Vietnamese" registered in the translate script's locale tables (desktop surface included — only th/tr are excluded); glossaryFile repointed .opencode/glossary/ → .codev/glossary/ (test expectations updated) so glossaries actually load from the fork's renamed config dir. The dictionaries themselves — packages/{app,ui}/src/i18n/vi.ts, packages/desktop/src/renderer/i18n/vi.ts, packages/console/app/src/i18n/vi.ts, packages/stats/app/src/i18n/vi.ts — and .codev/glossary/vi.md are fork-only new files (not tracked per policy). | Vietnamese docs are published on the fork's docs site (…/codev/vi/docs), and the product UI should match. Upstream has no vi locale, so the new files never collide with upstream content. | Keep our vi entries and the .codev glossary path; take upstream's surrounding script/dict changes. If upstream ever adds a vi locale, prefer our translations and reconcile key-by-key. | | packages/tui/src/component/prompt/autocomplete.tsx, packages/opencode/src/cli/cmd/run/footer.prompt.tsx, packages/core/src/filesystem/search.ts, packages/tui/test/fixture/tui-sdk.ts, packages/opencode/test/cli/run/footer.view.test.tsx, packages/core/test/filesystem/search.test.ts | @ mention autocomplete lists file/directory results before references/agents/MCP resources; TUI and run-CLI menus cap the leading files so up to 3 non-file matches stay in the first visible page. TUI and run-CLI file resources track menu visibility so opening on an empty query fetches (previously nothing refetched until the query changed); core's ripgrep-fallback find returns its first entries for an empty query to mirror fff. Tests: tui-sdk fixture's FetchHandler gains an optional method arg, renderFooter accepts findFiles/agents overrides + a mention-menu ordering test, core search tests cover the fallback's empty query (fork-only test file: packages/tui/test/prompt-autocomplete.test.tsx). The legacy web prompt originally carried the same reorder (fork-only prompt-input/at-options.ts); it was deleted with the legacy prompt input (see the removal row below), and the V2 composer's files-first ordering is tracked in its own session-ui row (PR #289). | Files are the dominant @ use case; they should rank first and appear on a bare @. | Keep our ordering/reserve logic, resource source tracking, empty-query branch, fixture/test additions; take upstream's surrounding changes. | | packages/slack/.gitignore, sdks/vscode/.gitignore | Added the missing trailing newline (content unchanged) | Upstream's copies end without a newline; POSIX text files should end with one, and tools that concatenate or append to these files misbehave without it. | Whitespace-only: keep the trailing newline; take upstream's entries. | | packages/tui/src/config/keybind.ts, packages/tui/src/component/prompt/index.tsx, packages/tui/src/app.tsx | Permission-mode indicator + shift+tab cycle. keybind.ts: added permission_mode_cycle: keybind("shift+tab", ...) + its permission.mode CommandMap entry; agent_cycle_reverse default "shift+tab" → "none" (tab still cycles agents forward; previous-agent stays rebindable). prompt/index.tsx: the footer badge next to the agent name always renders in prompt mode — gray manual (theme.textMuted) in normal mode, red auto (theme.error) in auto mode (upstream only showed gray auto); the footer's shortcut hints gain a shift+tab modes text (via a modeShortcut useCommandShortcut("permission.mode") memo) left of tab agents, in the same Show gate (see the hints-and-usage row above for when that gate renders). app.tsx: "permission.mode" added to appBindingCommands so the binding is gathered. | The permission mode is always visible (manual/auto) with auto in red as a danger signal, and shift+tab toggles it without opening the palette. | Keep the permission_mode_cycle definition + mapping, the agent_cycle_reverse "none" default, the badge Show block, the modeShortcut memo + hint text, and the appBindingCommands entry; take upstream's surrounding changes. If upstream reworks the auto badge or rebinds shift+tab, re-apply on top and re-audit the shift+tab conflict. | | packages/core/src/filesystem/search.ts, packages/core/src/ripgrep/binary.ts, packages/core/src/filesystem.ts, packages/core/src/tool/read-filesystem.ts, packages/core/test/filesystem/search.test.ts, packages/core/test/location-filesystem.test.ts, packages/core/test/ripgrep-binary.test.ts, packages/core/test/preload.ts | Windows file-search fallback robustness. search.ts: the background ripgrep index walk no longer dies silently (Effect.orDie in a forked fiber) — each attempt resets the accumulated file/directory state, failures log a warning ("file index scan failed, file search may be empty"), and the walk retries forever on a 1s exponential backoff capped at 30s spacing, so installing rg mid-session self-heals; directory entries are suffixed and detected with "/" instead of path.sep (both ripgrep and fff layers), and fallback find queries normalize \ to / and collapse doubled slashes (directory tab-expansion appends / to an already-/-suffixed entry). binary.ts: Effect.cached on ripgrep binary resolution (which memoizes failure exits for the process lifetime) replaced with a success-only cache behind a semaphore, so a failed rg download (offline, blocked proxy) retries on the next use instead of bricking search until restart; the download tail tries GitHub first (each attempt bounded to 30s) and falls back to the CoDev landing-page mirror (the base64-encoded MIRROR constant plus /rg-<version>-<node platform>-<node arch>[.exe], a raw executable — no archive/extract — staged in a scoped temp dir then copied into place), via Effect.catchCause so a proxy block page (200 + HTML) that dies in extract as a defect still reaches the mirror; on win32, resolution also checks for a bundled rg.exe next to process.execPath (shipped inside the Windows platform packages by script/build.ts, copied beside the codev binary by postinstall.mjs) — after PATH, before the cache and the network, so a bare npm i -g codev-code searches without any download. filesystem.ts list + read-filesystem.ts list: directory suffix path.sep → "/"; fs.list also /-normalizes path.relative output. Tests: search.test.ts pins "/"-suffixed directories, adds query-normalization coverage, and a stub-Ripgrep scan-retry test asserting the partial first walk is discarded; location-filesystem.test.ts pins "src/"; ripgrep-binary.test.ts adds mocked-HTTP coverage of the fallback order, success caching, and failure non-caching; preload.ts redirects XDG_CACHE_HOME to a fresh temp dir so no test can read or clobber the developer's real ~/.cache/codev. | On Windows fff is disabled by default (upstream e4300e9b74), so the @ mention popup and file search depend entirely on the ripgrep fallback; a missing rg.exe plus one failed download left the index permanently empty with no user-visible signal (the CI-workflows row's choco preinstall papered over the same swallowed failure). \-suffixed directory entries also broke endsWith("/") directory-mime detection in the run CLI and poisoned drill-down queries with backslashes that can never match the /-normalized index. | Keep our retry/reset scan, success-only binary cache, "/" suffixes, and query normalization; take upstream's surrounding changes. If upstream reworks the fallback or re-enables fff on win32, preserve the invariants: entry paths are /-normalized with /-suffixed directories on every platform, index-scan failures are logged and retried, a failed binary download is never cached, and the GitHub download keeps its CoDev-mirror fallback — the mirror filename scheme must match what codev-landing-page hosts under public/docs/code/downloads (and codevhub's RG_VERSION pin in its src/lib/ripgrep.ts); bump all three together when changing VERSION. Keep the win32 bundled-sibling (process.execPath) check ahead of the cache and network — the bundled copy follows script/build.ts's own RG_VERSION (default "latest"), so this VERSION does not describe it. | | packages/tui/src/context/permission.tsx, packages/tui/src/app.tsx, packages/tui/src/ui/dialog-confirm.tsx, packages/tui/src/ui/toast.tsx | Auto-approve consent gate. permission.tsx: mode always starts "normal" (upstream initialized it from args.auto); toggle() replaced by set(mode) plus a per-run consented flag + consent(). app.tsx: shared enableAutoApprove() (title in the AUTO_APPROVE_TITLE module const, body from an autoApproveMessage accessor, and a shorter one-line autoApproveToast accessor for the toast, which only has a few seconds on screen; both redden the word "dangerous" with theme.error and are passed unevaluated because renderables can only be created during render) shows a DialogConfirm opened on cancel and, on confirm, a warning toast under the same title, then sets the mode; both entry points funnel through it — an onMount when args.auto is set, and the permission.mode command (shift+tab / palette), which enables via the gate and disables directly. Consent is asked once per run; later toggles are silent. dialog-confirm.tsx: optional defaultChoice prop + 5th DialogConfirm.show arg selecting the initially-active button (omitted keeps upstream's confirm-first default); message widened to JSX.Element \| (() => JSX.Element) and called at the render site. toast.tsx: same message widening, resolved by a message() helper in Toast() (fork-only test file: packages/tui/test/auto-approve-consent.view.test.tsx). | Auto-approve is dangerous, so --auto (and its hidden aliases) and the first shift+tab into auto require explicit confirmation; declining or dismissing the dialog stays in manual mode, opening on cancel keeps a stray return from granting it, and the risk word is red like the auto badge. | Keep the consent flag + gate flow, the toggle() removal, the defaultChoice prop, and the accessor-aware message on both dialog and toast; take upstream's surrounding changes. If upstream adds new writers of the permission mode, route their enable path through enableAutoApprove(). | | packages/opencode/src/session/system.ts | Upstream's provider() branch chain is renamed select() and provider() now returns select(model).map(rebrand); inside select() the kimi branch also matches minimax; environment() gained a leading line naming the product and the codev command | The prompts the model receives are upstream's, so it identified itself as opencode and sent users to upstream's issue tracker and docs. Rewriting names as the prompt is assembled keeps the prompt/*.txt files byte-identical to upstream, so their hunks always apply and a newly added upstream brand mention is rebranded automatically. The identity also goes in the environment block because an agent's own prompt replaces the provider prompt wholesale (see llm/request.ts), and the environment block is on every path. MiniMax is a primary model for this fork's gateway and had no branch at all, so it fell through to PROMPT_DEFAULT, which is written for Claude; PROMPT_KIMI contains nothing Kimi-specific and is upstream's prompt for open-weight agentic models. | Keep the wrapper, the identity line, and minimax on the kimi branch; take upstream's select() body verbatim otherwise — never hand-rebrand the prompt text it returns. If upstream adds its own MiniMax branch, drop ours and keep theirs. A new upstream prompt file or branch needs nothing here; test/session/brand.test.ts fails if its wording isn't covered by the rules in packages/codev-brand. | | packages/opencode/src/tool/registry.ts | The assembled tool description is wrapped in rebrand(...) | Tool descriptions are model-visible and name the product (tool/lsp.txt). One wrapper at the point where every tool's final description is built covers all of them, including text upstream adds later. | Keep the wrapper around whatever expression upstream's description becomes; take upstream's surrounding changes. | | packages/core/src/plugin/skill.ts, packages/opencode/src/skill/index.ts, packages/core/test/plugin/skill.test.ts | Built-in skill customize-opencode → customize-codev in both registrations (plus location /builtin/customize-codev.md); descriptions rewritten to name CoDev Code; the markdown body wrapped in rebrand(...); Skill.get/Skill.require resolve the old name through a lookup helper; test expectations updated | The skill's name and description are listed in the system prompt, so they are among the most visible brand strings the model sees; the body is upstream's config guide. The alias keeps existing configs and skill permission rules that name customize-opencode working. | Keep our name, the alias, and the rebrand wrapper; take upstream's customize-opencode.md body verbatim (it is deliberately not hand-edited for branding). | | packages/opencode/src/command/template/initialize.txt | "OpenCode" → "CoDev Code" (×2) | /init writes this guidance into the user's AGENTS.md. Edited in place rather than wrapped because the file is already fork-divergent (config-file rename row) and barely churns. | Keep our two strings; take upstream's surrounding text and rebrand any new "OpenCode" mention. | | packages/core/package.json | Added dependency "@codev/brand": "workspace:*" | The built-in skill registration rebrands its body through the fork-only packages/codev-brand. | Keep our dependency line; take upstream's other fields. | | turbo.json | Added tasks "@codev/sandbox#test" and "@opencode-ai/desktop#test" (both no deps, no outputs) | CI's bun turbo test only runs test tasks registered here; the fork-only sandbox package's tests must gate PRs, and the desktop package ships 21 test files that ran nowhere until its task was registered. Neither needs ^build: both packages' workspace dependencies export TypeScript source. | Keep our task entries; take upstream's other tasks. | | Sandbox project label fallback: packages/app/src/context/global.tsx | enrich() (the per-server project-list decorator) gains one fork line: when the merged project record has no name yet, it fills it from the fork-only sandboxProjectHostName(conn, worktree, primary) (packages/app/src/sandbox/projects.ts), which maps a sandbox connection's guest paths back to host folder basenames — conn.projectDir for the primary (matched by the synced primary directory, or the /workspace literal before sync loads) and conn.mounts[].path for extras. Non-sandbox connections and named projects are untouched (the helper returns undefined / the branch is skipped). | A fresh sandbox project renders displayName() = name-or-worktree-basename, and inside the guest the primary worktree is /workspace, so every new sandbox flashed the label "workspace" until adoptFolderName's server-side rename round-tripped (guest reports the project → project.update → project.updated event). The fallback is display-only; the durable rename through the server is unchanged and takes precedence the moment it lands. | Keep the one if (!base.name) line after upstream's base construction and take upstream's surrounding enrich changes. If upstream renames or restructures enrich, re-apply the fallback wherever project records are decorated for the sidebar list. The helper stays in the fork-only sandbox module — do not inline it. | | packages/desktop/src/main/draft-store.{ts,test.ts} | Upstream's buffered SQLite draft store (v1.18.12) hardened: PRAGMA synchronous=NORMAL added beside WAL; putBlob no longer writes synchronously at call time — blobs ride the same 500ms pending buffer as documents and flush in the flush transaction, with getBlob reading pending first; the test loads ./draft-store lazily and test.skipIfs when node:sqlite is unavailable (Bun's test runtime lacks it; Electron main is Node and has it). | DatabaseSync runs on the Electron main thread. With SQLite's default synchronous=FULL, every 500ms flush commit fsyncs the WAL, and upstream's putBlob did a synchronous multi-MB write + fsync mid-composer-interaction — the same main-thread-blocked-on-disk failure class the fork's settings store was rebuilt to remove (see the async settings stores row). NORMAL confines fsync to checkpoints; buffering the blob keeps attachment drops off the interaction path. | Keep the pragma, the pendingBlobs buffer, and the lazy test import; take upstream's schema/logic changes. If upstream itself moves the store off the main thread (worker), take theirs and drop ours. | | Sidecar v2 gate + no CLI staging: packages/desktop/src/main/index.ts, packages/desktop/scripts/{predev,prebuild}.ts | The v2-sidecar opt-in env is CODEV_SIDECAR_V2 (hard-cutover rename of upstream's OPENCODE_SIDECAR_V2; default remains v1). The build scripts do not call downloadCliToResources() — predev keeps only the fork's install-electron + copy-icons + build-node steps, prebuild keeps the codev-vmm vzhelper build; the function itself stays in scripts/utils.ts unused to minimise future conflicts. | The v2 path launches upstream's opencode-cli binary (npm @opencode-ai/cli-*): a server that is not the fork's — it knows nothing of CODEV_* env, gateway config, or the fork's state dirs — and the fork's build network cannot assume public npm. The desktop code is ported (upstream refactored main/index.ts around it; excluding it would cascade conflicts every sync) but stays dead behind the gate until a codev-cli build/distribution exists. | Keep the gate name and the removed script calls; take upstream's v2 code changes. When codev-cli ships, also rebrand executableName(), the "opencode" username, and desktopStateNames in background-cli.ts (they are untouched upstream values today because the branch is unreachable). | | Model catalog source: packages/core/src/models-dev.ts (default URL + cache filename check), packages/cli/script/generate.ts, packages/ui/vite.config.ts | The models catalog default stays https://models.dev (override env already renamed CODEV_MODELS_URL); upstream moved the default to https://models.opencode.ai in v1.18.13. The models.json-vs-hashed cache filename check in models-dev.ts compares against the fork default — v1.18.19's sync briefly took upstream's literal there and every default-source fetch silently wrote to a hashed filename, caught by test/models.test.ts. Upstream's widened interleaved schema (InterleavedField) is taken. | The fork's fleet sits behind allowlist proxies and must not depend on upstream's hosted catalog; model lists normally come from the CoDev gateway anyway. The default URL and the cache-filename comparison must agree or the cache splits. | Keep models.dev in both places (they must match); take upstream's schema and surrounding changes. | | packages/app/src/components/session/session-new-design-view.tsx (kept), packages/app/src/pages/{home,new-session}.tsx (kept wholesale), upstream's packages/app/src/pages/{home,new-session}/* controller/view split (shipped unused) | Upstream v1.18.x split home.tsx (1.8k lines) and new-session.tsx into controller/view modules and deleted session-new-design-view.tsx. The fork's heavily diverged versions of all three are kept as-is (sidebar-based home, centred composer, fork menus); upstream's new pages/home/* and pages/new-session/* files are left in-tree but unreferenced. | The fork's home/new-session UX is a deliberate product divergence (see the sidebar/home rows); re-basing it onto the new controller split is real design work, not conflict resolution. Keeping upstream's unused modules costs dead code but keeps their future churn conflict-free. | Keep our three files; let upstream's split modules apply clean. When adopting the split, migrate the fork features into the controllers and delete the fork files + this row. | | packages/app/src/context/settings.tsx (+ settings.test.ts) | Upstream's pure layout-transition helpers (hasExistingWebState, initialAgentVisibility, shouldEnableNewLayout, layoutTransitionState, sunset timers) and their tests are kept, and initializeAgentVisibility is exposed (desktop onboarding calls it); but the runtime layout-migration effect and the layoutTransitionAvailable/newInterfaceNoticeVisible/dismiss exports are dropped, and newLayoutDesignsDefault/newLayoutDesignsUpgradeCutoff constants are re-declared beside the helpers. | The fork ships new-layout-only (newLayoutDesigns is a memo pinned true), so the transition/sunset runtime is dead weight and referenced state (layoutUpgrade) doesn't exist here — but the helpers are pure, tested, and keeping them makes upstream's test file apply clean. | Keep the dropped-effect shape; take upstream's helper changes. If upstream removes the machinery after their sunset completes, follow them. | | bunfig.toml minimumReleaseAgeExcludes | Added @ai-sdk/{openai,mistral,groq,openai-compatible,google-vertex} and ai-gateway-provider. | Upstream release manifests pin exact versions of these that can be younger than the 3-day gate on sync day (v1.18.19 pinned @ai-sdk/openai@3.0.98 hours after publish); they are vetted upstream pins — several carry our patches/ — not fresh picks. | Keep our entries; take upstream's other bunfig changes. | | packages/app/src/i18n/desktop-native.{ts,test.ts} | The shared desktop-native i18n layer's registries are the fork's single locale trim point: DESKTOP_NATIVE_LOCALES = ["en","es","vi"] (vi is the fork's own locale), LABELS/TAGS maps trimmed, the Norwegian tag special-case removed, and every DESKTOP_NATIVE_ENGLISH value rebranded "OpenCode"→"CoDev" ("OpenCode Zen" kept — upstream product name). Tests: label expectation covers en/es/vi; locale-detection cases replaced with en/es/vi + fall-back-to-English assertions. context/language.tsx keeps RTL_LOCALES as a ReadonlySet<string> so the direction helper survives the narrowed union. | This registry now feeds desktop's renderer/i18n/index.ts, context/language.tsx, and the native-menu bundle, so trimming here trims everywhere (see the locale-registries row for the pre-v1.18.19 shape). | Keep the three-entry registries and rebranded values; take upstream's structural changes; delete any other locale rows upstream adds. | | Sandbox/WSL delete cleanup + confirmation: packages/app/src/context/server.tsx, packages/app/src/components/dialog-select-server.tsx, packages/app/src/i18n/{en,es}.ts | server.tsx gains forget(key) beside remove(key): it deletes the persisted per-server projects/lastProject/recentlyClosed map entries for one key. dialog-select-server.tsx handleRemove (already diverged, see the renderer-slice row) additionally: for sandbox: and wsl: keys, awaits a confirmation through the fork-only packages/app/src/sandbox/dialog-delete.tsx / packages/app/src/wsl/dialog-delete.tsx (lazy-imported, pushed with dialog.push so it stacks on whatever dialog opened the menu) and returns without touching anything when declined; and calls server.forget(key) right after server.remove(key) for both key kinds. The controller gains a useDialog() binding for this. i18n gains the sandbox.delete.* and wsl.delete.* families. The two dialogs state different consequences on purpose: deleting a sandbox erases its disk (sessions, terminals, guest files — permanently; mounted host folders survive), while removing a WSL server destroys nothing — the app never unregisters distributions — it only forgets the app-side project state. | remove() only edits the stored HTTP server list; the project maps are keyed by server key and outlive the server, which is right for an HTTP server that still exists remotely but wrong for a deleted sandbox or removed distro — a recreated sandbox with the same name inherited the old projects (server.projects["sandbox:<name>"]) and, via the leftover recentlyClosed, silently refused to seed folders closed in the previous sandbox's life; a re-added distro likewise inherited its previous project list. And deleting a sandbox erases guest data permanently, so it must not be a one-click menu action; the confirmations live in handleRemove because the settings rows and the generic server list all funnel through it. | Keep the confirm-and-forget block at the top of handleRemove and the forget calls between server.remove(key) and the awaited platform removals; take upstream's surrounding changes. forget is fork-only API — if upstream reworks the persisted server record shape, re-point the three deletes at the new maps. Do not gate forget behind the platform removal succeeding: the tab/workspace cleanup ahead of it already treats the server as gone. HTTP removals deliberately keep their maps — the server still exists remotely and re-adding it should restore its projects. If the app ever gains real distro unregistration, the WSL dialog's "nothing is deleted" wording must change with it. | | Vanished-stream retry + visibility + persisted retry parts: packages/opencode/src/provider/error.ts, packages/opencode/src/session/{processor,retry,prompt,message-v2}.ts (plus the fork-only packages/opencode/src/session/resume.ts), packages/session-ui/src/components/message-part.tsx, packages/tui/src/routes/session/index.tsx, packages/ui/src/i18n/{en,es}.ts, tests packages/opencode/test/session/{retry,message-v2,processor-effect}.test.ts, packages/opencode/test/cli/run/run-process.test.ts | error.ts: new ProviderError.StreamVanishedError. processor.ts: ProcessorContext.produced (set on text-delta/tool-input-start/tool-call, reset per attempt); after the stream drains, a turn with no output, no error, and finish undefined/"unknown" fails with StreamVanishedError so the existing SessionRetry.policy retries it (visible "retrying… attempt #N" status); an Effect.catchIf(() => ctx.produced, …) ahead of Effect.retry keeps any failure of an attempt that did produce output away from the policy, which therefore only ever sees attempts with nothing to duplicate: by default the failure goes to halt, but when the caller passed the new resumable input and the policy would have retried it (and the turn was not aborted), no error is written and the failure is exposed as Handle.interruption while process returns "continue". For a resumable caller the post-drain fail also covers a turn that streamed text or partial tool input but no complete tool call (ProcessorContext.called, set on tool-call) before ending without a finish frame, so a quietly truncated reply takes the same resume path as an errored one; a turn that reached a complete tool call is left alone, because a provider that reports an unrecognized finish reason on tool turns would otherwise be backed off and capped on every one of them. retry.ts: retryable() gains an optional attempt param (policy passes meta.attempt); STREAM_VANISHED_CODE matches metadata.code and stops after STREAM_VANISHED_MAX_RETRIES (5) with a vanished-specific backoff that plateaus at STREAM_VANISHED_MAX_DELAY (8s — 2s/4s/8s/8s/8s; these errors are synthesized locally and never carry Retry-After, so delay() owns them in a dedicated branch), so a deterministic provider quirk cannot re-bill turns forever while flaky proxies get a few extra tries. message-v2.ts: maps StreamVanishedError → APIError{isRetryable:true, metadata.code}; new transportInterruptCode() walks the cause chain for ECONNRESET/EPIPE/ETIMEDOUT/UND_ERR_SOCKET so undici's mid-stream TypeError("terminated") (cause SocketError "other side closed") becomes a retryable APIError instead of an unretryable Unknown (AbortedError when ctx.aborted). prompt.ts: the run loop creates its processor with resumable: true and, when a turn comes back with an interruption, resumes instead of rerunning — it writes a retry part on the partial message, publishes the retry status, sleeps SessionRetry.delay, and continues the loop, appending SessionResume.PROMPT as a trailing, never-persisted user message to the next request (same mechanism as MAX_STEPS_PROMPT) so the history does not end on an assistant message; a loop-local resumes counter is reset by any turn that is not interrupted and, past SessionResume.MAX_ATTEMPTS (5), the error is written to the message and the loop breaks. The loop-exit "the reply may be incomplete" error this row used to add is gone: upstream now continues the loop on an "unknown" finish (sync #333), which made that block unreachable; quiet endings after text now go through the resume branch instead of upstream's bare continue, which had no backoff, no cap, no transcript record, and left a text-only history ending on an assistant message. Persisted retry parts: the schema's dormant RetryPart (already in @opencode-ai/schema/session-v1 and the generated SDKs, previously never written or rendered) is now written per retry attempt — SessionRetry.policy's set callback gains the parsed error, and processor.ts persists {type:"retry", attempt, error, time} via session.updatePart before publishing the live retry status — and rendered by a new PART_MAPPING["retry"] component in session-ui's message-part.tsx (error-card style, message + "retried - attempt #N" line; new i18n key ui.messagePart.retry.retried en/es) and by a PART_MAPPING["retry"] entry in the TUI's routes/session/index.tsx (a muted one-liner aligned with the transcript — error-colored Retried label, (attempt #N) · <error message truncated to 120>, mirroring the footer's live Retrying (attempt N) · message wording; fork-only view test packages/tui/test/retry-part.view.test.tsx). Upstream's two codev run subprocess tests that pin the old contract ("unknown stream finish preserves partial output and exits 0" — renamed here to "…retries and preserves partial output" — and "--format json records partial output for an unknown stream finish") are updated to the retrying behaviour: each queues an explicit reply after llm.fail(...) so the retry consumes it instead of the test server's auto-"ok", stdout carries both turns, and the json test asserts the extra step_start,text,step_finish trio with the vanished step still finishing "unknown" and the retried one "stop". What they lock in is unchanged: an SSE error item is not fatal — exit stays 0, pre-failure output survives, and the provider message never reaches stderr. | A provider (or corporate proxy) that drops the SSE mid-turn ended the run with no retry, no error card, no banner — the agent "just stopped" and only a user-typed "continue" resumed it. Empty turns are safely retried (nothing to duplicate). Turns with output are never rerun: upstream retried a retryable mid-stream error regardless of output, and nothing removes the failed attempt's parts, so the rerun wrote the reply a second time into the same message and could re-execute a tool that had already run — a case the transport-code walk made far more common. They are resumed instead, from the partial message, which has to stay error-free because toModelMessages drops errored assistant messages from the history the model sees. | Keep our produced tracking, the post-drain fail, the catchIf guard ahead of Effect.retry (it must stay before the retry, not become a while option on it — the schedule step runs first and would persist a retry part for a retry that never happens), the resumable/interruption pair, the called exemption for tool turns, and the run loop's resume branch with its trailing prompt (compaction deliberately does not pass resumable: a summary cut short must fail, not be continued), the classifier cap, the transport-code walk, and the two retry-part renderers (session-ui and TUI PART_MAPPING["retry"] entries); take upstream's surrounding changes. If upstream ships its own abnormal-finish handling, prefer theirs and drop ours where they overlap. Good upstream-PR candidate. The two run-process.test.ts expectations are ours only because the retry is: if upstream's version of that file conflicts, re-apply the queued-reply shape on top of their test body; if their behaviour is adopted instead, revert these two tests to upstream wholesale. | | packages/session-ui/src/v2/components/prompt-input/interaction.ts | The @ mention popup's contextList grouping delegates to the fork-only at-options.ts beside it (atSuggestionCategory + compareAtCategories, unit-tested in the fork-only at-options.test.ts): suggestion groups rank recent → file → reference → agent → resource instead of upstream's reference → agent → resource → recent → file, so file results lead in the V2 composer — the prompt the desktop and web apps render now that the legacy layout is retired. The grouped menu scrolls, so no row-reserve logic is needed (unlike the TUI/run-footer variants), and default selection stays index 0, so Enter accepts the top file match. contextList's resolver also searches on an empty query — it used to return the fixed entries plus open tabs without asking, so a bare @ listed no files at all and the ordering above had nothing to order (the same empty-query gap the TUI and run-footer fixed in the @ autocomplete row above). Covered by the fork-only packages/app/test-browser/prompt-at-menu.test.ts and the e2e packages/app/e2e/regression/prompt-at-menu-files.spec.ts. | Files are the dominant @ use case; the earlier files-first reorder (see the @ autocomplete row above) covered the TUI, the legacy web prompt, and the codev run footer but missed the V2 composer. | Keep the two-line delegation to at-options.ts; take upstream's surrounding changes. If upstream restructures the suggestion list, re-apply the ranking wherever context suggestions are grouped. | | Legacy prompt input removed: deleted packages/app/src/components/prompt-input.tsx and its now-orphaned submodules packages/app/src/components/prompt-input/{attachments,files,paste,transient-state}.ts, {context-items,drag-overlay,image-attachments,slash-popover}.tsx, image-attachments.css, attachments.test.ts, packages/app/test-browser/{prompt-attachments,prompt-transient-state,prompt-at-options}.test.ts (plus the fork-only at-options{,.test}.ts); edits in packages/app/src/pages/session.tsx, packages/app/src/pages/session/composer/{session-composer-region-controller.ts,todo-panel-motion.stories.tsx} | The fork hardcodes newLayoutDesigns to true (see the settings.tsx row), so the legacy PromptInput — the fallback of session.tsx's <Show when={newSessionDesign()}> — could never render. session.tsx now creates the V2 controller unconditionally and always mounts PromptInputV2Composer (no Show); session-composer-region-controller.ts imports PromptInputState from prompt-input/contracts (where it is defined) instead of the deleted barrel; the todo-panel-motion story fills its composer-region promptInput slot with a static styled placeholder instead of the legacy component (its unused controls mock removed). Kept: prompt-input/{contracts,history,history-store,placeholder,submit,editor-dom,build-request-parts,submission-state}.ts and their tests — the V2 composer path still uses them. | Dead code: retired with the legacy layout, kept compiling for months, and PR #56-style fixes had to be applied to it for nothing. | Keep deleted — git rm the upstream side of modify/delete conflicts on these files, and delete any new module upstream adds under prompt-input/ that only the legacy PromptInput imports. Take upstream changes to the kept shared modules. If upstream restructures session.tsx's composer block, re-apply "always render the V2 composer" and keep the legacy fallback out; if upstream rewires the story's input slot, keep our placeholder. If the fork ever wants the legacy layout back, restore from upstream, not from history. |

| Work side can be hidden: packages/app/src/context/settings.tsx, packages/app/src/components/settings-v2/general.tsx, packages/app/src/i18n/{en,es,vi}.ts | The Work side and the Code/Work tab strip render only when workSideAvailable() (fork-only packages/app/src/workspace/side.ts) says so: sandbox-capable platform AND (a sandbox exists OR the new general.showWork preference is on). settings.tsx gains showWork (default true) with its accessor/setter beside showCustomAgents; settings-v2's Advanced section gains a "Show Work" switch, wrapped in <Show when={platform.sandboxes}> so non-sandbox builds never offer it; i18n gains settings.general.row.showWork.{title,description}. The fork-only wiring is in workspace/tabs.tsx: a useWorkAvailable() hook feeds both useWorkspaceSide()'s effectiveSide call and WorkspaceTabs's <Show> (which previously gated on platform.sandboxes), so the side and its strip can never disagree — a hidden strip with Work selected would strand the user on sandbox servers with no way back. An undefined sandbox list means "not loaded", not "none", and keeps Work (the same race the Code/Work split row calls out); the main process answers sandbox-get-state synchronously, so that window is sub-frame. Tests: packages/app/test-browser/workspace-side.test.ts covers the four workSideAvailable branches. | Sandboxes are a heavyweight opt-in feature (a VM per sandbox), and a desktop profile that does not use them pays for the strip anyway: a Work side with nothing in it that offers to build one. The gate lets that profile put both away. It ships on rather than off, though — the default was flipped in #318 before either shipped. Hiding Work until a sandbox exists also hides the only evidence that sandboxes exist, so the profile most likely to have the strip taken away is the one that would then never discover the feature; and it silently changes the sidebar for every existing install. On by default keeps the app as it is and makes putting Work away an explicit choice. Owning a sandbox shows Work whatever the preference says. | Keep the workSideAvailable gate on both useWorkspaceSide and WorkspaceTabs — gating only one reintroduces the stranded-side bug. Take upstream's surrounding settings/i18n changes. If upstream adds its own sandbox-visibility preference, prefer theirs and drop showWork. Never collapse on an unloaded sandbox list. Keep the default true: with it false, workSideAvailable is load-bearing on every launch, and a profile with no sandboxes loses the strip without having asked. | | packages/ui/src/context/dialog.tsx (again) | The Kobalte.Overlay lost its onClick, so a backdrop click no longer closes the top dialog. Escape (the window keydown listener) and onOpenChange (the close button) are untouched, as is the persistent guard on both. | A stray click in the dimmed area around a dialog dismissed it and discarded whatever it held — a half-filled sandbox form, a provider mid-connect. Dismissal is now always deliberate: the corner close button or Escape. | Keep the overlay handler-less. If upstream adds behaviour to the overlay, take it minus the close. Pairs with the onInteractOutside guards on the three dialog contents below — removing the handler alone is not enough, Kobalte's own dismissable layer closes on outside pointerdown too. | | Dialogs close on a button, never on an outside click: packages/ui/src/components/dialog.tsx, packages/ui/src/components/dialog.css, packages/ui/src/v2/components/dialog-v2.tsx, packages/ui/src/v2/components/dialog-v2.css, packages/ui/src/components/image-preview.tsx, plus the *.stories.tsx beside the two dialogs | All three Kobalte.Contents take onInteractOutside={(e) => e.preventDefault()}, which is what actually blocks Kobalte's dismissable layer (the context row above only removes the explicit overlay click). Both Dialogs gained closeLabel?/hideClose? and now render the Kobalte.CloseButton themselves, as the last child of their content, absolutely positioned at the content's top-right — v1 at 16px/20px (12px/12px when data-no-header), v2 at 14px/14px, both at z-index: 30 so they clear the settings tab header's sticky z-index: 10. The button was removed from v2's DialogHeader (which loses its hideClose/closeLabel props and its data-hide-close attribute) and from v1's header Switch, whose action branch used to replace it; action now simply renders. Headers reserve the corner in padding-right (v1 52px, v2 42px) and v1's title takes flex: 1; both containers take a min-height (v1 52px, v2 48px) so a short fit dialog cannot clip the button. aria-label defaults to ui.common.close through useI18n — the first v2 component to use the UI i18n context, which has an English fallback. | Every dialog needed a visible way out once the backdrop stopped being one, including the dozen whose call sites passed hideClose. Pinning the button to the content corner rather than laying it out in the header is what lets header-less dialogs (settings, release notes, the command palettes) have one at all; keeping it inside Kobalte.Content keeps it inside the focus trap and the modal's pointer-events layer. | Keep the corner button and the onInteractOutside guards. Upstream owns the header/body/footer layout — take its changes and re-apply the padding-right reserve and the close button on top. If upstream adds its own close button to DialogHeader, drop ours from Dialog rather than shipping two. | | Dialog close buttons at the call sites: packages/app/src/sandbox/{dialog-add,dialog-add-default,dialog-delete,dialog-mounts}.tsx, packages/app/src/wsl/{dialog-add-server,dialog-delete}.tsx, packages/app/src/components/{dialog-manage-models,dialog-connect-provider,dialog-select-model-unpaid-v2,session-actions-menu,settings-v2/dialog-server-v2}.tsx, packages/app/src/components/gateway/dialog-configure.tsx, packages/app/src/components/dialog-select-file.tsx, packages/app/src/components/dialog-command-palette-v2.css | Dropped every hideClose/closeLabel prop from DialogHeader (the button moved to Dialog, and hiding it is no longer wanted anywhere it was hidden). dialog-configure passes hideClose on its Dialog instead — it is the configure gate, which the dialog stack also marks persistent, so a close button there would be inert. The two search-first dialogs reserve the corner the button now occupies: dialog-select-file via [&_[data-slot=list-search-wrapper]]:pr-10, the v2 command palette via padding-right on .command-palette-v2-search. | Fork-only sandbox/WSL/gateway dialogs aside, these are upstream files whose only change is a removed prop. | Take upstream's version of these call sites and re-drop hideClose. The hideClose on dialog-configure is fork-only and must stay. |

| Gateway key recovery: packages/app/src/desktop-menu.ts, packages/app/src/i18n/{en,es}.ts, packages/desktop/src/preload/{index,types}.ts | A "Log in/Configure" item (menu.login in the dicts) dispatching the fork-only gateway.apiKey.new command — in the macOS app menu under Settings, and in the Windows/Linux File menu (platforms: ["windows"]) under its Settings, the same split Settings itself uses since those builds render no app menu. The label names the sign-in, not the key: re-authenticating is what the item is reached for, minting a key is the side effect, and the usage-reporting toast's Log in button dispatches the same command. The command id stays gateway.apiKey.new — it is the wire name the desktop menus dispatch. i18n gains command.gateway.apiKey.new and toast.usageReporting.paused.{title,description,action.login}. Preload gains the gateway-sso-ensure-key, gateway-sso-consume-signin-nudge, gateway-sso-get-account and gateway-sso-logout channels on the existing gatewaySso namespace — the last two back the sidebar's account entry (see the persistent app sidebar row): getAccount reads the signed-in identity from the shared auth file, logout is the hub-parity sign-out (@codev/gateway's logout(), the port of codevhub logout: drop the SSO block, keep the api-key/config blocks, leave the force-login sentinel, revoke best-effort, clear the hub admin cookie) (the nudge rides this namespace because signing back in is its entire remedy, and GatewaySsoPlatform in the fork-only packages/app/src/gateway/types.ts is where it can be typed without touching upstream's platform.tsx). The logic is fork-only: packages/desktop/src/main/gateway-sso/controller.ts gains ensureKey(), the desktop twin of codev-hub's ensureFreshGatewayKey — validate the stored key against the gateway's /key/info (@codev/gateway's already-exported validateApiKey, previously with no production caller), bounded by a 2.5s race so a slow gateway cannot delay startup; a definite 401/403 tries silentSso + fetchApiKey and re-saves the record with every other field intact, returning the credential for the renderer to merge into the local server's auth registry (only the renderer writes to the server); anything else is left alone. packages/app/src/components/gateway/gateway-configure-gate.tsx (fork-only) registers the command behind its existing driver election and runs the pre-flight once per window after sync settles, opening DialogConfigure only on needs-login; on mount it also consumes the stats-push sign-in nudge (see the desktop stats push row) and raises a persistent toast whose Log in action is that same openConfigure. DialogConfigure gains a dismissable prop driving both setPersistent and hideClose: the gate's copy stays trapped (the provider-setup screen the dialog rework deliberately exempts), while the two new entry points — the menu command and the pre-flight — are escapable, since a provider is configured in both cases and the dialog otherwise has no close button, no backdrop dismissal and no Escape. | Gateway keys expire on a schedule CoDev does not control, and nothing in the desktop app noticed: DialogConfigure had exactly one caller, the gate, which fires only when no provider is connected — and a dead key is still a connected provider, so every message 401'd with the sign-in flow unreachable. The silent re-mint mirrors what the hub already does for CLI launches, so the common case costs the user nothing. | Keep the menu item on both platforms (dropping the platforms: ["windows"] twin loses it entirely on Windows/Linux, which render no app menu) and keep the pre-flight's "only a definite rejection acts" rule — treating an unreachable gateway as a dead key would lock people out on a flaky network. Take upstream's surrounding menu/i18n/preload changes. Keep the "Log in/Configure" label paired with the toast action label — they name one flow. |

| packages/core/src/filesystem/search.ts, packages/app/e2e/utils/mock-server.ts | fffLayer's find drops the worktree-root entry: fff ranks the root itself into an empty-query result, where it leads every list as a bare / — a mention naming no file — while the ripgrep fallback never emits it. The e2e mock server gains an agents config field (defaulting to the previous single primary agent) so a spec can supply subagents; the @ menu hides primary agents, so the default list left nothing to rank files against. | Empty-query @ menus (see the @ autocomplete rows) made the root visible for the first time, at the top of the list. Both search layers should answer the same way. | Keep the root filter and the agents default; take upstream's surrounding changes. |

| Sidebar scoping: packages/app/src/pages/home-model.tsx, packages/app/e2e/utils/mock-server.ts | projectDirectories returns [] when no project is selected instead of the union of every project's directories, so Pinned and Recents list one project's sessions or none; and a homeSelectionSeed helper plus a once-per-model effect opens a window with no stored selection on the server's last project (projects.last()), but only while that server still lists it. The seeding is guarded so a deliberate deselection — clicking the selected project — keeps showing nothing. The e2e mock server gains a projects config field (defaulting to [config.project]) for specs that need more than one. | A fresh window has a selection with a server but no directory, so the union listed every project on the server at once: sessions from folders you were not in, and — because a row is marked open when its session has a tab anywhere while tab strips are per-project — rows in two different projects looking active at the same time. | Keep the empty return and the seeding guard together: seeding without the empty return changes nothing, and the empty return without seeding opens every fresh window on a blank sidebar. Take upstream's surrounding changes. |

| Startup update prompt (terminal): packages/opencode/src/cli/cmd/tui.ts, packages/opencode/src/cli/upgrade.ts, packages/tui/src/app.tsx | TuiInput gains onReady?: () => void; run forwards it to <App>, which calls it once its event.on(...) handlers are registered. cmd/tui.ts drops the setTimeout(..., 1000) around client.call("checkUpgrade") and passes that call as onReady instead. upgrade() replaces const kind = getReleaseType(...) / if (config.autoupdate === "notify" || kind !== "patch") with const silent = config.autoupdate === true && getReleaseType(...) === "patch" / if (!silent), so a newer version notifies unless the user opted into automatic patch installs. autoupdate: false and CODEV_DISABLE_AUTOUPDATE still skip the check, and CODEV_ALWAYS_NOTIFY_UPDATE is untouched. | The startup check announced its result with the installation.update-available event, which the TUI has had a confirm dialog for all along — but nobody ever saw it. Rpc.emit is a bare postMessage, the TUI's handlers are registered while it renders, and the check on a one-second timer resolved a few hundred milliseconds before that: the event was delivered into an empty handler set and lost, every launch. Ordering the check behind the TUI instead of behind a timer is the only version of this that cannot race. The second change is about consent: upstream's default installs a patch release in place, unasked, which on this fork's fleet is a metered download and a version change nobody agreed to. | Keep onReady and the onReady-triggered checkUpgrade; take upstream's other changes to all three files. Do not restore the timer — it is the bug. If upstream moves the App's event handlers, keep the props.onReady?.() call after the last of them. |

| packages/app/src/pages/layout-new.tsx (update prompt) | One import and one element: mounts the fork-only src/components/dialog-update.tsx, whose <UpdatePrompt /> renders nothing and opens the update dialog the first time the desktop updater reports each version as available or ready (tracked per version, not once per run, so a newer release found after Later is announced too). | The main process has always checked for updates at launch (updater.start()), but on the new layout the only thing that ever said so was a small Update button in the titlebar, and only once the download had finished. Users went weeks without noticing a waiting update. The legacy layout's UpdateAvailableToast in pages/layout.tsx is unreachable — newLayoutDesigns is always true. | Keep the import and the element; take upstream's other changes. If upstream reworks this file's layout, the element can sit anywhere inside the shell: it renders nothing. |

| Home-directory crawl guard: packages/core/src/filesystem/search.ts, packages/app/src/components/directory-picker-domain.ts, packages/desktop/src/main/index.ts, packages/core/test/filesystem/search.test.ts, packages/app/src/components/directory-picker-domain.test.ts | search.ts: new exported unindexableDirectory(directory) (true for Global.Path.home — honoring CODEV_TEST_HOME — and filesystem roots) and a new shallowLayer; the layer chooser becomes Layer.unwrap(Effect.gen(...)) reading Location.Service and picks shallowLayer for unindexable directories, fff/ripgrep otherwise. shallowLayer.find answers from one readDirectoryEntries of the location itself (direct children only, symlinks/specials dropped, same fuzzysort/empty-query/limit semantics as the index layers); its glob/grep are the on-demand ripgrep implementations, extracted from ripgrepLayer into a shared ripgrepGlobGrep effect (ripgrepLayer now spreads it — no behavior change there). directory-picker-domain.ts: createDirectorySearch's bare-word branch (no /, ~, or drive root in the input) matches direct children via the existing file.list-based match() when the base is the picker's home or a filesystem root, instead of find.files({directory: base}). desktop index.ts: the startup process.chdir(homedir()) becomes process.chdir(join(app.getPath("userData"), "cwd")) (mkdir first, after app.setPath("userData", ...) so it lands under the channel's own folder; still inside try/catch, homedir import dropped). | Materializing the search service walks everything under its location — fff at creation, ripgrep as an eagerly forked scan. Rooted at ~ that walk opens files inside Downloads, Desktop, Documents and the photo/music library bundles, so macOS raised a permission prompt for each (confirmed in the TCC database: Desktop, Media Library, Downloads granted; Photos denied — the last two one second apart, one walk). The desktop app reached a home-rooted location two ways: the picker's bare-word search runs find.files against its ~ base, and any server request naming no directory falls back to process.cwd() (workspace-routing), which the sidecar inherited as ~ from the main process's chdir. Reproduced end to end against a decoy home: one directoryless find/file request read sentinel files inside Downloads/ and Photos Library.photoslibrary/; with the guard the same request returns home's direct children and no sentinel is touched. | Keep all three pieces and take upstream's surrounding changes. If upstream reworks the search layers, preserve the invariant: no eager index (fff create or ripgrep scan) is ever built for the home directory or a filesystem root — find there must answer from a single readdir of the location itself. If upstream moves the desktop chdir, keep it pointed at an app-owned empty folder, never homedir() (and never leave it in /, which breaks ripgrep). The picker guard is belt-and-braces for older/remote servers; do not "simplify" it away because the server also guards. | | Prompt permission mode: packages/app/src/components/prompt-input-v2.tsx, packages/app/src/context/permission.tsx, packages/app/src/pages/session/use-session-commands.tsx, packages/app/src/components/settings-general.tsx, packages/app/src/components/settings-v2/general.tsx, packages/app/src/i18n/{en,es,vi}.ts | prompt-input-v2.tsx: the modelControl slot now carries a fragment — upstream's <PromptInputV2ModelControl> plus the fork-only <PromptInputV2PermissionControl>, an Auto/Manual select built on session-ui's exported PromptInputV2Select (riding the model slot avoids a new slot in session-ui; it leads the fragment, so it sits between the agent select and the model select). It reads and writes the same client-side auto-accept state the permissions.autoaccept command toggles: a session takes an explicit override, a draft steers the directory default (src/components/prompt-input/permission-mode.ts, fork-only, holds that decision). A directory whose server config says permission: "allow" renders a non-interactive Auto with a tooltip, since such a server never asks. permission.tsx: enableConfiguredDirectory gains an autoByDefault parameter, passed as sideForServerKey(activeServer()) === "work", so Work directories seed to auto-accept, and exposes side() so the consent gate agrees with it. All three paths that turn auto-accept ON — the select, the permissions.autoaccept command in use-session-commands.tsx, and the Settings switch in both general.tsx files — now await useAutoAcceptConsent() (fork-only src/components/dialog-auto-accept.tsx): on Code, once per app run, a confirm dialog focused on Cancel plus a warning toast, matching the TUI's enableAutoApprove; Work never asks, since auto is the default there that nobody picked. Turning it off never asks. i18n: the prompt.permission.* and dialog.autoAccept.* families. | Users asked to pick Auto/Manual per session, with Code defaulting to manual and Work to auto. The Work default used to come from the sandbox guest config (permission: "allow", dropped in packages/desktop/src/main/sandbox/egress.ts — fork-only, untracked here): a server told to allow everything never emits permission.asked, so Manual had nothing to gate. Moving the default to the host makes both modes real on both sides while leaving the walk-away behaviour unchanged. | Keep the fork's fragment and re-apply upstream's changes to the <PromptInputV2ModelControl> inside it. The three enable paths each gained one await consent(...) line — keep them together, or the warning becomes theatre that one door skips. If upstream adds its own permission or mode control to the prompt toolbar, drop ours and re-point the Work default at whatever state theirs uses. enableConfiguredDirectory's extra parameter is additive — if upstream rewrites that function, keep the autoByDefault branch or the Work side loses its auto default. | | Message order across the ID wrap: packages/opencode/src/session/{message-v2,prompt}.ts (+ tests) | Server side, MessageV2 exports chronological(a, b) ((time.created, id) — upstream's private isAfter in latest() is the same order and is taken as-is), and the prompt loop's already-answered exit compares MessageV2.chronological(lastUser, lastAssistant) < 0 instead of upstream's lastAssistant.parentID === lastUser.id. Client side the divergence is GONE: upstream v1.18.19 converged on (time.created, id) ordering (utils/session-message.ts messageKey/compareMessages, linear findIndex for ID-only lookups), so the fork's utils/message-order.ts was deleted and its call sites now use upstream's helpers. | Upstream's ID encoder keeps only 48 bits of timestamp*4096, so the time prefix wraps every 2^36 ms (last on 2026-08-14). Assistants persisted by the fork before parentID existed would fail upstream's equality check and read as unanswered — the prompt loop would re-run a turn on old sessions — so the chronological comparison stays. | Keep the chronological export and the prompt-loop comparison; take upstream's surrounding changes. If upstream's parentID is ever backfilled into stored fork data, their equality check can replace this. | | Composer git branch indicator: packages/app/src/components/prompt-input-v2.tsx, packages/app/src/components/prompt-workspace-selector.tsx | The composer's modelControl fragment (see the prompt-permission-mode row) gains a third element after the model select: the fork-only PromptInputV2BranchIndicator, which feeds sync().data.vcs?.branch into the new-session PromptGitStatus chip, wrapped in text-v2-text-text-muted so it matches the ghost-muted control buttons beside it, and gated on a session id in the route so the new-session views (which already show the branch beside the project selector) don't render it twice. PromptGitStatus gains a separator?: boolean prop (default true) so the footer usage drops the leading "/" divider that the new-session row needs. | Existing sessions never showed which branch the session's directory is on. The new-session chip already renders it for both Code and Work servers — per-server sync supplies the guest branch on Work — so it is reused rather than duplicated. | Keep the separator prop and the indicator; take upstream's surrounding prompt-input and workspace-selector changes. If upstream adds its own branch/status chip to the composer footer, prefer theirs and drop ours. |

Known rebrand gaps

  • UI branding: TUI banner, default-theme accent, desktop/web app names, favicon/PWA assets, and the hardcoded TUI/web-app product strings are rebranded (see table). Still upstream-branded, deliberately: the packages/app and packages/desktop i18n en/es dicts are now rebranded (see the app-i18n and desktop-rebrand rows) except the real external-service names "OpenCode Zen" / "OpenCode Go" and the Zen free-models title, which stay because the fork routes to those actual services (opencode.ai/zen /go, kept per the URLs bullet); the remaining upstream-branded i18n is in other packages' dicts (revisit with a runtime display-name shim if it matters), favicon-v3.ico + social-share.png (binary formats we can't author well — need design assets; note the docs site serves the upstream-branded social-share.png as its og:image), the OAuth callback pages (packages/core/src/oauth/page.ts — its WORDMARK constant is a second copy of the opencode wordmark geometry, and the page's headline/message/ footnote strings and <title> all say "OpenCode"; the file asks to be kept in sync with packages/ui/src/components/logo.tsx, which has since moved to the codev wordmark, so rebranding it means the wordmark and the strings together or the page reads inconsistently), Desktop app icons are no longer a gap — see the icons row in the table. The ai.opencode.desktop appId and opencode:// scheme are no longer a gap either: both were renamed to ai.codev.desktop / codev:// (see the desktop identity row in the table).
  • URLs: links the app opens in the browser point at the fork — docs at the base in DOCS_URL (packages/codev-brand/src/index.ts, base64-encoded — the site is locale-routed and the Code docs live under /code; no trailing slash anywhere, the site 308-redirects …/themes/ to …/themes) and issue reports at github.com/quickbeard/codev-code/issues (see the docs-site links table row). Still upstream, deliberately: Zen/Go product URLs (opencode.ai/zen, /go, /auth — external products), $schema/theme.json/ desktop-theme.json ids, service endpoints (api.opencode.ai exchange, opencode.ai/s/ share links, social-cards, opencode.ai/changelog.json highlights fetch), the MCP OAuth client_uri, the remaining remote favicon image sources (opencode.ai/favicon* in app/src/entry.tsx + pages/layout/helpers.ts — the desktop renderer's copy is now local, see the notification-icon row), and opencode.ai URLs inside docs content (web-docs row). The Help submenu's feedback/bug links target the fork's issue templates and its "Support Forum" Discord item is deleted (docs-site links row); crash-report, security-advisory, and contribution links target github.com/quickbeard/codev-code (project-repo links row), while upstream-repo URLs that are genuine references (attribution, upstream issue citations, fixtures, disabled publish channels, unshipped console/stats/enterprise/desktop surfaces) stay.
  • Model-visible text: everything the model reads — provider system prompts, tool descriptions, and the built-in config skill — is rebranded at assembly time by the fork-only packages/codev-brand, so the prompt/*.txt and customize-opencode.md sources stay byte-identical to upstream. Add new rules there rather than editing prompt text; packages/opencode/test/session/brand.test.ts scans every embedded prompt and tool description and fails when a brand mention isn't covered, which is the tripwire for an upstream sync that introduces new wording. Deliberately preserved by those rules (they are identifiers, not branding): @opencode-ai/* imports, published plugin names such as opencode-gemini-auth, the $schema URL https://opencode.ai/config.json, opencode://, ai.opencode.*, and opencode.db.
  • Internal names: @opencode-ai/* package names and file names inside ~/.local/share/codev (opencode.db in released builds; opencode-<channel>.db, e.g. opencode-local.db, when running from source — see core/src/database/database.ts) keep upstream naming on purpose. Renaming them would be a permanent merge-conflict machine for names users never see. Do not "clean these up". But check that "users never see it" is actually true before filing something here — it was not for the desktop package. electron-builder derives user-visible names from packages/desktop/package.json name, so @opencode-ai/desktop was on screen as the Windows per-user install directory (%LOCALAPPDATA%\Programs\@opencode-aidesktop) and as the updater cache directory (@opencode-aidesktop-updater). Fixed without renaming the package: extraMetadata.name in electron-builder.config.ts overrides what the packager derives, so the internal id and the user-visible one are now decoupled (see that table row). The install directory has since stopped deriving from it at all — the assisted installer names it after productFilename (CoDev) — but the updater cache still does, so the override stays load-bearing. The desktop app's persisted store file names (%APPDATA%\ai.codev.desktop\opencode.settings and friends) were the same shape of leak and stayed upstream-branded until the troubleshooting docs had to name them to users. They are now codev.*, which — unlike a derived name — orphans existing state unless it is carried across, so the rename ships with a one-time migration (see the persisted-store-name row in the table).
  • VS Code extension: ships as a locally built .vsix (cd sdks/vscode && bun run vsix), not a Marketplace listing. Fully rebranded for that path: codev.* command IDs + CoDev Code titles, codev terminal launching codev --port, rebranded README body, and the CLI's IDE auto-install target points at CoDev.codev-code (see the sdks/vscode and ide/index.ts table rows). Before any actual Marketplace/OpenVSX publish, revisit: publisher: CoDev is still a PLACEHOLDER, not a registered account — and its mixed case is itself invalid for a real Marketplace publisher id (must be lowercase), so register the real fork-owned lowercase publisher and re-sync the manifest and the ide/index.ts install target (CoDev.codev-code) to the registered id; sdks/vscode/script/publish is still upstream-flavored (tag-driven, outputs dist/opencode.vsix, publishes via vsce/ovsx) and left untouched deliberately. The marketplace icon (images/icon.png) is now a fork-local generated PNG of the CoDev "c" badge (brand red #ee0033, the "dev" half of the wordmark, on #0a0a0a; source in images/icon.svg). The editor-title/terminal button glyph is a single fork-local images/button.svg — a transparent-background brand-red "c" that reads on both light and dark themes (replacing the two packages/identity bracket-mark symlinks). Until published, auto-install fails against real marketplaces — the local .vsix is the supported install.