diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 611d4cf44f75..f324ab456396 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,7 +16,7 @@ concurrency:
jobs:
check:
name: Check
- runs-on: blacksmith-8vcpu-ubuntu-2404
+ runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
@@ -75,7 +75,7 @@ jobs:
# limit stays at the default 4 so peak load per runner is unchanged.
test:
name: Test
- runs-on: blacksmith-8vcpu-ubuntu-2404
+ runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
@@ -118,7 +118,7 @@ jobs:
# isolation that flag buys is preserved exactly.
test_server:
name: Test Server ${{ matrix.shard }}
- runs-on: blacksmith-8vcpu-ubuntu-2404
+ runs-on: ubuntu-24.04
timeout-minutes: 10
strategy:
fail-fast: false
@@ -185,7 +185,7 @@ jobs:
# for checks that take under 3s, on the critical path of every PR.
rust:
name: Rust
- runs-on: blacksmith-4vcpu-ubuntu-2404
+ runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
@@ -219,7 +219,7 @@ jobs:
# the diff cannot be resolved, the lint runs.
mobile_native_changes:
name: Mobile Native Changes
- runs-on: blacksmith-2vcpu-ubuntu-2404
+ runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
@@ -297,7 +297,7 @@ jobs:
# Skip only on an explicit "no": a gate job that failed or errored leaves the
# output empty, and that must run the lint rather than silently skip it.
if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }}
- runs-on: blacksmith-6vcpu-macos-26
+ runs-on: macos-15
timeout-minutes: 10
steps:
- name: Checkout
@@ -325,7 +325,7 @@ jobs:
release_smoke:
name: Release Smoke
- runs-on: blacksmith-8vcpu-ubuntu-2404
+ runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
diff --git a/.github/workflows/fork.yml b/.github/workflows/fork.yml
new file mode 100644
index 000000000000..db2c92400fe2
--- /dev/null
+++ b/.github/workflows/fork.yml
@@ -0,0 +1,33 @@
+name: Fork
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+# Runs alongside upstream's own CI: ci.yml covers the project's checks and
+# tests, this workflow covers the fork-stack model. Both must be green.
+jobs:
+ fork-model:
+ name: Fork Stack Model
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ # Check the PR head itself; the default PR merge ref is a merge commit.
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+
+ - name: Fetch upstream base
+ run: |
+ git remote add upstream https://github.com/pingdotgg/t3code.git 2>/dev/null || true
+ git fetch upstream main --depth 100
+
+ - name: Check fork stack
+ run: bash scripts/fork-check.sh --upstream upstream
diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml
index b8b872f019f6..5849b71ddbb8 100644
--- a/.github/workflows/mobile-fingerprint-check.yml
+++ b/.github/workflows/mobile-fingerprint-check.yml
@@ -24,7 +24,7 @@ concurrency:
jobs:
fingerprint:
name: Native fingerprint diff
- runs-on: blacksmith-8vcpu-ubuntu-2404
+ runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts
index ce24afcb6284..ad2dd7371375 100644
--- a/apps/server/src/mcp/McpHttpServer.ts
+++ b/apps/server/src/mcp/McpHttpServer.ts
@@ -610,7 +610,7 @@ export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequest
Layer.provide(PullRequestsToolkitHandlersLive),
);
-export const ThreadsToolkitRegistrationLive = McpServer.toolkit(ThreadsToolkit).pipe(
+const ThreadsToolkitRegistrationLive = McpServer.toolkit(ThreadsToolkit).pipe(
Layer.provide(ThreadsToolkitHandlersLive),
);
diff --git a/apps/web/src/bundledDev.test.ts b/apps/web/src/bundledDev.test.ts
index 599b45d22524..e5324e003db8 100644
--- a/apps/web/src/bundledDev.test.ts
+++ b/apps/web/src/bundledDev.test.ts
@@ -32,6 +32,10 @@ it("initializes React refresh before a shared UI chunk runs in bundled dev", asy
for (const file of ["index.html", "src/bootstrap.ts", "src/lib/bootError.ts"]) {
await NodeFSP.copyFile(new URL(`../${file}`, import.meta.url), NodePath.join(root, file));
}
+ await NodeFSP.writeFile(
+ NodePath.join(root, "src/branding.ts"),
+ 'export const APP_BASE_NAME = "Chromeria";\n',
+ );
await NodeFSP.writeFile(
NodePath.join(root, "src/shared.tsx"),
"export function Shared() { return
ready
; }",
diff --git a/docs/fork.md b/docs/fork.md
new file mode 100644
index 000000000000..39e199336c54
--- /dev/null
+++ b/docs/fork.md
@@ -0,0 +1,245 @@
+# Fork maintenance
+
+This repo (`toolboxmd/t3code`) is a thin, rebasable layer on upstream
+`pingdotgg/t3code`. Upstream `main` is the base; fork work is a small stack of
+topic commits kept on top of it. This document describes the model, the
+maintenance routine, and the upstream extension points that would shrink the
+patch set further.
+
+## Model
+
+- **Base.** Upstream `main` (`https://github.com/pingdotgg/t3code.git`,
+ remote name `upstream`). Fork `main` is that base plus the stack below.
+- **Stack.** Fork work lives as topic commits on top of the current upstream
+ base. A topic PR is squash-merged, so each lands as one stack commit. No
+ merge commits: upstream updates are absorbed by rebasing the stack, never
+ by merging `upstream/main` into the fork.
+- **Absorption moves `main`.** Rebasing the stack rewrites `main`, which no
+ PR merge can do: squash or rebase merges would copy upstream commits under
+ new hashes and break the stack. An absorption is proven on its own branch
+ and then lands with `--force-with-lease` (see Routine). That push is a
+ human-approved step.
+- **New files first.** Features go in new files and new packages. Edits to
+ upstream-owned files stay minimal and every edited file is listed under
+ "Upstream edits" below and in the machine-checked allowlist
+ `scripts/fork-upstream-edits.txt` (one path per line).
+- **Remotes.** `origin` is `toolboxmd/t3code`; `upstream` is
+ `pingdotgg/t3code`. Never push to `upstream`, never send fork commits there
+ (offering extension points upstream is a separate human decision, out of
+ scope for routine maintenance).
+
+## Fork-only content
+
+New files owned by the fork (no upstream counterpart, always allowed):
+
+- `VISION.md`, `MISSION.md`, `OBJECTIVE.md`, `GLOSSARY.md`: Project Direction
+ and project language.
+- `assets/chromeria/`, `apps/web/public/chromeria-mark.png`: Chromeria icons.
+- `docs/fork.md`: this document.
+- `scripts/fork-rebase.sh`: the rebase routine.
+- `scripts/fork-check.sh`: the stack-model check run by CI.
+- `scripts/fork-maintenance.test.ts`: tests driving both scripts against
+ throwaway git fixtures (runs with upstream's own test suite).
+- `scripts/fork-upstream-edits.txt`: allowlist of upstream files the fork
+ may modify.
+- `.github/workflows/fork.yml`: fork CI (the stack check).
+
+## Upstream edits
+
+Upstream-owned files modified by the fork. `scripts/fork-check.sh` fails on
+any modified upstream file missing from `scripts/fork-upstream-edits.txt`.
+
+- Chromeria branding (#12, #13): `apps/desktop/package.json` (product
+ name), `apps/desktop/src/app/DesktopEnvironment.ts` (names, userData
+ directory), `scripts/build-desktop-artifact.ts` (app id, artifact name, no
+ update feed, icons), `scripts/lib/brand-assets.ts` (icon paths), and the
+ expectations in `apps/desktop/src/app/DesktopAppIdentity.test.ts`,
+ `apps/desktop/src/app/DesktopPreReadyPlatform.test.ts`,
+ `scripts/build-desktop-artifact.test.ts` and
+ `scripts/lib/brand-assets.test.ts`.
+- Chromeria name in the web UI (#14): `apps/web/index.html`,
+ `apps/web/src/branding.ts`, `apps/web/src/lib/bootError.ts`, and the
+ branding fixture in `apps/web/src/bundledDev.test.ts`, plus the
+ components `T3Wordmark.tsx`, `chat/MessagesTimeline.tsx`,
+ `onboarding/WelcomeWizard.tsx`, `settings/IntegrationsSettings.tsx`,
+ `settings/ThemePreviewCircles.tsx` and `sidebar/SidebarChrome.tsx` under
+ `apps/web/src/components/`, with expectations in
+ `apps/web/src/bootstrap.test.ts` and `apps/web/src/branding.test.ts`.
+- Child threads (#8, #10): `apps/server/src/mcp/McpHttpServer.ts`
+ registers the threads MCP toolkit; `apps/web/src/components/AgentsPanel.tsx`
+ links agents to their child threads; `apps/web/src/components/Sidebar.tsx`
+ and `apps/web/src/components/LegacySidebar.tsx` hide child threads from the
+ sidebar.
+- `scripts/build-desktop-artifact.ts` (#10): the packaged-bundle
+ self-containment probe clears an inherited `ELECTRON_RUN_AS_NODE`.
+- `apps/server/src/entrypoint.test.ts` (#10): resolves the fixture directory
+ so the test passes under macOS's symlinked `TMPDIR`.
+- `.github/workflows/ci.yml`, `.github/workflows/mobile-fingerprint-check.yml`:
+ `blacksmith-*-ubuntu-2404` runners become `ubuntu-24.04` and
+ `blacksmith-*-macos-*` becomes `macos-15` (see CI).
+
+## Chromeria desktop app
+
+Chromeria is the fork's desktop build: bundle id `md.toolbox.chromeria`,
+Electron userData `~/Library/Application Support/chromeria`, no update feed.
+It installs next to upstream "T3 Code (Alpha)" and reads the same T3 home
+(`T3CODE_HOME`, else `~/.t3`, state in `/userdata`), so its threads
+are the upstream app's threads.
+
+**Never run Chromeria and T3 Code (Alpha) on the same T3 home at once.** Both
+would start a server on one database. Quit one fully (Cmd-Q, not just close
+the window) before opening the other. To try Chromeria while the upstream
+app keeps running, give Chromeria its own home, which starts with no threads:
+
+```bash
+open -na /Applications/Chromeria.app --env T3CODE_HOME="$HOME/.t3-chromeria"
+```
+
+Build (Apple Silicon, from a checkout of fork `main`):
+
+```bash
+pnpm install --frozen-lockfile
+pnpm dist:desktop:dmg:arm64 # writes release/Chromeria--arm64.dmg
+```
+
+Install or update (quit Chromeria first):
+
+```bash
+hdiutil attach release/Chromeria-*-arm64.dmg -nobrowse -mountpoint /tmp/chromeria-dmg
+rm -rf /Applications/Chromeria.app
+ditto /tmp/chromeria-dmg/Chromeria.app /Applications/Chromeria.app
+hdiutil detach /tmp/chromeria-dmg
+codesign --force --deep --sign - /Applications/Chromeria.app
+open /Applications/Chromeria.app
+```
+
+Gotchas:
+
+- The local build is unsigned. Without the ad-hoc `codesign` step macOS
+ reports the app as damaged or refuses to open it.
+- Each build has a new ad-hoc signature, so macOS may ask again for
+ permissions granted to the previous build (screen recording, folders).
+- There is no auto-update. Updating is: pull fork `main`, rebuild,
+ reinstall as above.
+- Both apps register the `t3code://` URL scheme, so a browser sign-in
+ callback may open either app.
+- Both apps prefer backend port 3773; whichever starts second takes the next
+ free port.
+
+## Routine
+
+`scripts/fork-rebase.sh` fetches `upstream`, rebases the current branch's
+stack onto `upstream/main`, runs the fork check, optionally runs the full
+proof, and optionally pushes. It reports conflicts per upstream file and never
+force-pushes a published branch without proof passing in the same run.
+
+The proof runs in a host-neutral environment, because upstream's tests assume
+CI's Linux host. On a Mac, run from a T3 thread, about 50 upstream tests
+otherwise fail without any fork change: T3 Code desktop leaks
+`ELECTRON_RUN_AS_NODE=1` into agent shells, macOS `TMPDIR` sits behind the
+`/var` to `/private/var` symlink, and Homebrew's `brew` on `PATH` adds
+provider probes. To run the proof by hand:
+
+```bash
+env -u ELECTRON_RUN_AS_NODE TMPDIR="$(cd "$TMPDIR" && pwd -P)/" \
+ PATH="$(printf '%s' "$PATH" | tr ':' '\n' | grep -v '^/opt/homebrew' | paste -sd: -)" \
+ sh -c 'pnpm install --frozen-lockfile && pnpm exec vp run -r typecheck && pnpm exec vp lint && pnpm exec vp run -r test && pnpm exec vp fmt --check'
+```
+
+```bash
+# Report the stack and what an absorption would replay, without changing anything.
+scripts/fork-rebase.sh --dry-run
+
+# Absorb upstream updates (fetch + rebase + fork check).
+scripts/fork-rebase.sh
+
+# Absorb and run the full fork proof (required before pushing a rebased stack).
+scripts/fork-rebase.sh --proof
+
+# Absorb, prove, and publish (refuses unless --proof passed in the same run;
+# uses --force-with-lease, never --force; refuses on main).
+scripts/fork-rebase.sh --proof --push
+```
+
+An absorption, end to end:
+
+```bash
+git fetch origin
+git switch -c fork/absorb- origin/main
+scripts/fork-rebase.sh --proof --push
+# Open a PR to main for CI and review. Do not merge it through GitHub.
+# After approval, land the proven branch on main:
+git push --force-with-lease=main: origin fork/absorb-:main
+```
+
+Open topic PRs then rebase onto the new `main` (`git rebase --onto
+origin/main `).
+
+On conflict the script stops at the failing commit, prints the conflicted
+upstream files (`git diff --name-only --diff-filter=U`), and exits non-zero
+with the rebase left in progress for manual resolution (`git status` to see
+the files, resolve, `git rebase --continue`, then re-run the script). Record
+the conflicted files and the resolution in the absorption log below and in the
+PR.
+
+Rules:
+
+- Never `git push --force`. A published rebased branch moves only via
+ `git push --force-with-lease` after the same proof passed.
+- `main` moves only by squash-merging a topic PR, or by the approved
+ `--force-with-lease` landing of a proven absorption branch.
+- Bind proof to the final candidate: any rebase or conflict resolution
+ invalidates earlier proof, so the proof must run last.
+
+## CI
+
+Upstream's jobs run on Blacksmith runners the fork does not have, so they
+would queue forever. The fork remaps the PR and `main` workflows (`ci.yml`,
+`mobile-fingerprint-check.yml`) to GitHub-hosted runners, which are free for
+public repositories. Release, deploy, preview and Windows workflows keep
+their Blacksmith labels: they need upstream secrets and must not run here.
+
+`.github/workflows/fork.yml` runs on every pull request and on pushes to
+`main`, alongside `ci.yml`. It adds the `upstream` remote, fetches it, and
+runs `scripts/fork-check.sh`, which verifies:
+
+- the stack on top of the upstream base contains no merge commits,
+- the stack is small (at most 20 commits),
+- every modification to an upstream-owned file is allowlisted in
+ `scripts/fork-upstream-edits.txt` (new fork-only files are always fine),
+- this document exists.
+
+## Absorption log
+
+One entry per absorbed upstream update: exact base and candidate commits,
+elapsed time, conflicts by file (or "none"), and proof.
+
+## Candidate upstream extension points
+
+Generic seams the fork's planned work (Model Router threads, OpenBot mode)
+will need. Each is written so that, if upstream ever adopted it, the stated
+fork patch would disappear. Offering any of them upstream is a human decision
+and explicitly out of scope for this maintenance work; this list only records
+what each would remove.
+
+- **`parentThreadId` on `thread.create`.** `ThreadCreateCommand`
+ (`packages/contracts/src/orchestration.ts`) and `ThreadCreatedPayload` carry
+ no parent link, so Model Router job threads would need a fork patch that
+ smuggles the parent linkage (via title prefix, metadata, or a parallel
+ fork-owned map). A real optional `parentThreadId` field would remove that
+ patch entirely.
+- **Thread visibility independent of archive state.** Threads are listed or
+ hidden by existing lifecycle flags; there is no "hidden from the sidebar
+ but reachable from the Agents panel" concept, so Model Router job threads
+ would need a fork patch in the sidebar query plus the Agents panel
+ (`apps/web/src/components/AgentsPanel.tsx`). A first-class visibility flag
+ would remove both edits.
+- **Right-panel tab registry.** `RightPanelSurface`
+ (`apps/web/src/rightPanelStore.ts`) is a closed union and
+ `apps/web/src/components/RightPanelTabs.tsx` renders a fixed tab set, so an
+ OpenBot Computer tab would require editing both upstream files. A registry
+ (fork registers a tab kind without touching the union or renderer) would
+ remove those edits.
+- **Thread persona/owner marker.** `ThreadCreatedPayload` carries no agent
+ identity, so a Bot-as-thread-with-persona would need a fork patch to attach
+ and render it. An optional upstream persona field would remove that patch.
diff --git a/scripts/fork-check.sh b/scripts/fork-check.sh
new file mode 100755
index 000000000000..9eeab392e583
--- /dev/null
+++ b/scripts/fork-check.sh
@@ -0,0 +1,86 @@
+#!/usr/bin/env bash
+# fork-check.sh: verify the fork stack model on the current branch.
+# Usage: scripts/fork-check.sh [--base ] [--upstream ] [--max-commits ]
+# Exits 0 when the stack is a small, merge-free stack whose upstream-file
+# modifications are all allowlisted in scripts/fork-upstream-edits.txt.
+set -euo pipefail
+
+BASE=""
+UPSTREAM="upstream"
+MAX_COMMITS=20
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --base) BASE="$2"; shift 2 ;;
+ --upstream) UPSTREAM="$2"; shift 2 ;;
+ --max-commits) MAX_COMMITS="$2"; shift 2 ;;
+ -h|--help)
+ sed -n '2,6p' "$0"
+ exit 0
+ ;;
+ *)
+ echo "fork-check: unknown argument: $1" >&2
+ exit 2
+ ;;
+ esac
+done
+
+ROOT="$(git rev-parse --show-toplevel)"
+cd "$ROOT"
+
+if [[ ! -f docs/fork.md ]]; then
+ echo "fork-check: FAIL: docs/fork.md is missing." >&2
+ exit 1
+fi
+
+if [[ -z "$BASE" ]]; then
+ if git rev-parse --verify --quiet "$UPSTREAM/main" >/dev/null; then
+ BASE="$(git merge-base HEAD "$UPSTREAM/main")"
+ else
+ BASE="$(git merge-base HEAD origin/main)"
+ fi
+fi
+
+echo "fork-check: base: $(git log --oneline -1 "$BASE")"
+
+MERGES="$(git log --merges --format='%h %s' "$BASE"..HEAD)"
+if [[ -n "$MERGES" ]]; then
+ echo "fork-check: FAIL: merge commits in the fork stack (rebase, do not merge):" >&2
+ echo "$MERGES" >&2
+ exit 1
+fi
+
+COUNT="$(git rev-list --count "$BASE"..HEAD)"
+echo "fork-check: stack: $COUNT commit(s) on top of base (limit $MAX_COMMITS)."
+git log --oneline "$BASE"..HEAD || true
+if [[ "$COUNT" -gt "$MAX_COMMITS" ]]; then
+ echo "fork-check: FAIL: stack exceeds $MAX_COMMITS commits; split the work." >&2
+ exit 1
+fi
+
+# Files changed across the stack, classified against the base: a changed path
+# that does not exist in the base is a new fork-only file (always fine).
+# Anything else (modified or deleted upstream file) must be allowlisted.
+ALLOW="$ROOT/scripts/fork-upstream-edits.txt"
+ALLOWLIST="$(grep -v '^\s*#' "$ALLOW" | grep -v '^\s*$' || true)"
+FAIL=0
+while IFS= read -r path; do
+ [[ -z "$path" ]] && continue
+ if git cat-file -e "$BASE:$path" 2>/dev/null; then
+ if printf '%s\n' "$ALLOWLIST" | grep -qxF "$path"; then
+ echo "fork-check: modified (allowlisted): $path"
+ else
+ echo "fork-check: FAIL: upstream file modified but not allowlisted: $path" >&2
+ FAIL=1
+ fi
+ else
+ echo "fork-check: new fork file: $path"
+ fi
+done < <(git diff --name-only "$BASE"..HEAD)
+
+if [[ "$FAIL" -ne 0 ]]; then
+ echo "fork-check: FAIL: list the file in scripts/fork-upstream-edits.txt and docs/fork.md, or move the change to a new file." >&2
+ exit 1
+fi
+
+echo "fork-check: OK."
diff --git a/scripts/fork-maintenance.test.ts b/scripts/fork-maintenance.test.ts
new file mode 100644
index 000000000000..41ea31c64b44
--- /dev/null
+++ b/scripts/fork-maintenance.test.ts
@@ -0,0 +1,148 @@
+// Fork maintenance routine: drives the real scripts against tiny git fixtures.
+// @effect-diagnostics nodeBuiltinImport:off - Drives the real bash/sh routines against throwaway git fixtures.
+import * as NodeChildProcess from "node:child_process";
+import * as NodeFS from "node:fs";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import { describe, expect, it } from "vite-plus/test";
+
+const scriptsDir = import.meta.dirname;
+const forkCheck = NodePath.join(scriptsDir, "fork-check.sh");
+const forkRebase = NodePath.join(scriptsDir, "fork-rebase.sh");
+
+function git(cwd: string, ...args: Array): string {
+ return NodeChildProcess.execFileSync("git", args, { cwd, encoding: "utf8" });
+}
+
+function makeFixture(): string {
+ const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-fork-check-"));
+ git(root, "init", "-q", "-b", "work");
+ git(root, "config", "user.email", "fork-check@test");
+ git(root, "config", "user.name", "fork-check");
+ NodeFS.mkdirSync(NodePath.join(root, "docs"), { recursive: true });
+ NodeFS.mkdirSync(NodePath.join(root, "scripts"), { recursive: true });
+ NodeFS.writeFileSync(NodePath.join(root, "docs", "fork.md"), "# Fork maintenance\n");
+ NodeFS.writeFileSync(
+ NodePath.join(root, "scripts", "fork-upstream-edits.txt"),
+ "# allowlist\nscripts/fork-upstream-edits.txt\n",
+ );
+ NodeFS.writeFileSync(NodePath.join(root, "upstream-owned.txt"), "base\n");
+ git(root, "add", "-A");
+ git(root, "commit", "-qm", "base");
+ return root;
+}
+
+function runCheck(cwd: string, ...args: Array): { status: number; output: string } {
+ try {
+ const output = NodeChildProcess.execFileSync("bash", [forkCheck, ...args], {
+ cwd,
+ encoding: "utf8",
+ });
+ return { status: 0, output };
+ } catch (error) {
+ const result = error as { status?: number; stdout?: string; stderr?: string };
+ return {
+ status: result.status ?? 1,
+ output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
+ };
+ }
+}
+
+function baseOf(root: string): string {
+ return git(root, "rev-parse", "HEAD").trim();
+}
+
+describe("fork-check", () => {
+ it("passes for fork-only new files on top of the base", () => {
+ const root = makeFixture();
+ const base = baseOf(root);
+ NodeFS.writeFileSync(NodePath.join(root, "docs", "new-fork-doc.md"), "fork only\n");
+ git(root, "add", "-A");
+ git(root, "commit", "-qm", "fork: new file");
+ const result = runCheck(root, "--base", base);
+ expect(result.status).toBe(0);
+ expect(result.output).toContain("fork-check: OK.");
+ });
+
+ it("fails on unallowlisted upstream edits and passes once allowlisted", () => {
+ const root = makeFixture();
+ const base = baseOf(root);
+ NodeFS.writeFileSync(NodePath.join(root, "upstream-owned.txt"), "forked\n");
+ git(root, "add", "-A");
+ git(root, "commit", "-qm", "fork: touch upstream file");
+ const denied = runCheck(root, "--base", base);
+ expect(denied.status).toBe(1);
+ expect(denied.output).toContain("not allowlisted: upstream-owned.txt");
+
+ NodeFS.appendFileSync(
+ NodePath.join(root, "scripts", "fork-upstream-edits.txt"),
+ "upstream-owned.txt\n",
+ );
+ git(root, "add", "-A");
+ git(root, "commit", "-qm", "fork: allowlist the edit");
+ const allowed = runCheck(root, "--base", base);
+ expect(allowed.status).toBe(0);
+ expect(allowed.output).toContain("modified (allowlisted): upstream-owned.txt");
+ });
+
+ it("fails on merge commits in the stack", () => {
+ const root = makeFixture();
+ const base = baseOf(root);
+ git(root, "checkout", "-qb", "side");
+ NodeFS.writeFileSync(NodePath.join(root, "side.txt"), "side\n");
+ git(root, "add", "-A");
+ git(root, "commit", "-qm", "side");
+ git(root, "checkout", "-q", "work");
+ NodeFS.writeFileSync(NodePath.join(root, "work.txt"), "work\n");
+ git(root, "add", "-A");
+ git(root, "commit", "-qm", "work");
+ git(root, "merge", "--no-ff", "-m", "merge side", "side");
+ const result = runCheck(root, "--base", base);
+ expect(result.status).toBe(1);
+ expect(result.output).toContain("merge commits in the fork stack");
+ });
+
+ it("fails when docs/fork.md is missing", () => {
+ const root = makeFixture();
+ const base = baseOf(root);
+ NodeFS.rmSync(NodePath.join(root, "docs", "fork.md"));
+ git(root, "add", "-A");
+ git(root, "commit", "-qm", "fork: drop the doc");
+ const result = runCheck(root, "--base", base);
+ expect(result.status).toBe(1);
+ expect(result.output).toContain("docs/fork.md is missing");
+ });
+});
+
+describe("fork-rebase safety rules", () => {
+ it("refuses to push without proof in the same run", () => {
+ const root = makeFixture();
+ try {
+ NodeChildProcess.execFileSync("bash", [forkRebase, "--push"], {
+ cwd: root,
+ encoding: "utf8",
+ });
+ expect.unreachable("fork-rebase --push without --proof must fail");
+ } catch (error) {
+ const result = error as { status?: number; stderr?: string };
+ expect(result.status).toBe(1);
+ expect(result.stderr).toContain("refusing to push without proof");
+ }
+ });
+
+ it("refuses to push main directly", () => {
+ const root = makeFixture();
+ git(root, "checkout", "-qb", "main");
+ try {
+ NodeChildProcess.execFileSync("bash", [forkRebase, "--proof", "--push"], {
+ cwd: root,
+ encoding: "utf8",
+ });
+ expect.unreachable("fork-rebase --push on main must fail");
+ } catch (error) {
+ const result = error as { status?: number; stderr?: string };
+ expect(result.status).toBe(1);
+ expect(result.stderr).toContain("refusing to push main directly");
+ }
+ });
+});
diff --git a/scripts/fork-rebase.sh b/scripts/fork-rebase.sh
new file mode 100755
index 000000000000..dac55e0d7c67
--- /dev/null
+++ b/scripts/fork-rebase.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+# fork-rebase.sh: absorb upstream updates into the fork stack.
+# Usage: scripts/fork-rebase.sh [--upstream ] [--dry-run] [--proof] [--push]
+# --dry-run report the stack and pending upstream commits without changing anything.
+# --proof run the full fork proof after a successful rebase.
+# --push publish the branch (requires --proof in the same run, uses
+# --force-with-lease, never --force, and refuses on main).
+# On conflict the rebase is left in progress and the conflicted upstream files
+# are reported; resolve them, run `git rebase --continue`, then re-run this script.
+set -euo pipefail
+
+UPSTREAM="upstream"
+DRY_RUN=0
+PROOF=0
+PUSH=0
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --upstream) UPSTREAM="$2"; shift 2 ;;
+ --dry-run) DRY_RUN=1; shift ;;
+ --proof) PROOF=1; shift ;;
+ --push) PUSH=1; shift ;;
+ -h|--help)
+ sed -n '2,10p' "$0"
+ exit 0
+ ;;
+ *)
+ echo "fork-rebase: unknown argument: $1" >&2
+ exit 2
+ ;;
+ esac
+done
+
+if [[ "$PUSH" -eq 1 && "$PROOF" -ne 1 ]]; then
+ echo "fork-rebase: refusing to push without proof passing in the same run; re-run with --proof --push." >&2
+ exit 1
+fi
+
+BRANCH="$(git branch --show-current)"
+if [[ "$PUSH" -eq 1 && "$BRANCH" == "main" ]]; then
+ echo "fork-rebase: refusing to push main directly; push an absorption branch, then land it as docs/fork.md describes." >&2
+ exit 1
+fi
+
+ROOT="$(git rev-parse --show-toplevel)"
+cd "$ROOT"
+START="$SECONDS"
+
+echo "fork-rebase: fetching $UPSTREAM..."
+git fetch "$UPSTREAM" main
+
+TARGET="$(git rev-parse "$UPSTREAM/main")"
+BASE="$(git merge-base HEAD "$TARGET")"
+echo "fork-rebase: branch: $BRANCH"
+echo "fork-rebase: upstream: $(git log --oneline -1 "$TARGET")"
+echo "fork-rebase: base: $(git log --oneline -1 "$BASE")"
+echo "fork-rebase: stack ($(( $(git rev-list --count "$BASE"..HEAD) )) commit(s)):"
+git log --oneline "$BASE"..HEAD || true
+PENDING="$(git rev-list --count "$BASE".."$TARGET")"
+echo "fork-rebase: pending upstream commits: $PENDING"
+if [[ "$PENDING" -gt 0 ]]; then
+ # `|| true`: head closing the pipe is not a failure under pipefail.
+ git log --oneline "$BASE".."$TARGET" | head -n 20 || true
+fi
+
+if [[ "$DRY_RUN" -eq 1 ]]; then
+ echo "fork-rebase: dry run; nothing changed."
+ exit 0
+fi
+
+if [[ "$PENDING" -eq 0 ]]; then
+ echo "fork-rebase: already up to date; nothing to replay."
+else
+ echo "fork-rebase: rebasing stack onto $UPSTREAM/main..."
+ if ! git rebase "$UPSTREAM/main"; then
+ echo "fork-rebase: CONFLICT. The rebase is left in progress." >&2
+ echo "fork-rebase: conflicted upstream files:" >&2
+ git diff --name-only --diff-filter=U | while IFS= read -r f; do
+ echo "fork-rebase: - $f" >&2
+ done
+ echo "fork-rebase: stopped at: $(git log --oneline -1 2>/dev/null || echo '(unknown)')" >&2
+ echo "fork-rebase: resolve each file (hint: git log $UPSTREAM/main -- ), run \`git rebase --continue\`, then re-run this script." >&2
+ exit 1
+ fi
+ echo "fork-rebase: rebase clean (no conflicts)."
+fi
+
+echo "fork-rebase: running fork check..."
+bash "$ROOT/scripts/fork-check.sh" --upstream "$UPSTREAM"
+
+if [[ "$PROOF" -eq 1 ]]; then
+ echo "fork-rebase: running full fork proof..."
+ # Run the proof in a host-neutral subshell. Upstream tests assume CI's Linux
+ # host: an inherited ELECTRON_RUN_AS_NODE (T3 Code desktop leaks it into
+ # agent shells) changes spawned command environments, macOS's /var ->
+ # /private/var TMPDIR symlink breaks path equality, and a Homebrew `brew`
+ # on PATH adds extra provider probes.
+ (
+ unset ELECTRON_RUN_AS_NODE
+ TMPDIR="$(cd "${TMPDIR:-/tmp}" && pwd -P)/"
+ PATH="$(printf '%s' "$PATH" | tr ':' '\n' | grep -v '^/opt/homebrew' | paste -sd: -)"
+ export TMPDIR PATH
+ pnpm install --frozen-lockfile
+ pnpm exec vp run -r typecheck
+ pnpm exec vp lint
+ pnpm exec vp run -r test
+ pnpm exec vp fmt --check
+ )
+ echo "fork-rebase: proof passed."
+fi
+
+if [[ "$PUSH" -eq 1 ]]; then
+ if git rev-parse --verify --quiet "refs/remotes/origin/$BRANCH" >/dev/null; then
+ echo "fork-rebase: publishing rebased stack with --force-with-lease..."
+ git push --force-with-lease origin "$BRANCH"
+ else
+ echo "fork-rebase: publishing new branch..."
+ git push -u origin "$BRANCH"
+ fi
+fi
+
+echo "fork-rebase: done in $((SECONDS - START))s."
diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt
new file mode 100644
index 000000000000..9bedb2a279dc
--- /dev/null
+++ b/scripts/fork-upstream-edits.txt
@@ -0,0 +1,41 @@
+# Canonical allowlist of upstream-owned files the fork may modify.
+# One repo-relative path per line. Lines starting with # and blank lines are ignored.
+# New fork-only files never need listing here; only modifications (or deletions)
+# of files that already exist on the upstream base must be listed.
+# scripts/fork-check.sh enforces this list; keep "Upstream edits" in docs/fork.md
+# describing the same files.
+
+# Chromeria branding (#12, #13)
+apps/desktop/package.json
+apps/desktop/src/app/DesktopAppIdentity.test.ts
+apps/desktop/src/app/DesktopEnvironment.ts
+apps/desktop/src/app/DesktopPreReadyPlatform.test.ts
+scripts/build-desktop-artifact.ts
+scripts/build-desktop-artifact.test.ts
+scripts/lib/brand-assets.ts
+scripts/lib/brand-assets.test.ts
+
+# Chromeria name in the web UI (#14)
+apps/web/index.html
+apps/web/src/bootstrap.test.ts
+apps/web/src/branding.test.ts
+apps/web/src/branding.ts
+apps/web/src/bundledDev.test.ts
+apps/web/src/components/T3Wordmark.tsx
+apps/web/src/components/chat/MessagesTimeline.tsx
+apps/web/src/components/onboarding/WelcomeWizard.tsx
+apps/web/src/components/settings/IntegrationsSettings.tsx
+apps/web/src/components/settings/ThemePreviewCircles.tsx
+apps/web/src/components/sidebar/SidebarChrome.tsx
+apps/web/src/lib/bootError.ts
+
+# Child threads in the Agents panel and threads MCP toolkit (#8, #10)
+apps/server/src/entrypoint.test.ts
+apps/server/src/mcp/McpHttpServer.ts
+apps/web/src/components/AgentsPanel.tsx
+apps/web/src/components/LegacySidebar.tsx
+apps/web/src/components/Sidebar.tsx
+
+# CI on GitHub-hosted runners (#6)
+.github/workflows/ci.yml
+.github/workflows/mobile-fingerprint-check.yml