Skip to content

Sync with upstream v0.1.3-nightly.20261025.3669 (review only, do not merge) - #41

Merged
martinothamar merged 0 commit into
mainfrom
sync/2026-10-25
Oct 25, 2026
Merged

martinothamar merged 0 commit into
mainfrom
sync/2026-10-25

Conversation

@martinothamar

@martinothamar martinothamar commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Review only — do not merge. This shows the rewrite of main for the upstream sync. Once it is approved, the old tip is archived as archive/main-2026-10-25 and main is force-pushed to this branch. GitHub then marks this PR merged.

Base

Upstream nightly v0.1.3-nightly.20261025.3669 (5f64c5e), the latest, moving from v0.1.3-nightly.20261024.3617 (27e0c68). That's 109 upstream commits. Among them is upstream#24867, which fixes desktop release builds after the vite-plus 1.4 upgrade; the Fork release workflow needs it. upstream/main is 6 small commits past this nightly, which isn't enough reason to sync to a non-nightly ref.

Mapping

Old main New Commit
2b44d95 + d4dbb5e 2709f97 chore(fork): set up development, releases and maintenance (… #38, #40)
3aec799 b24a049 fix(server): wake watched pull requests for users sharing the agent account (#5)
272c27e f0990bf feat: agentctl Sandboxes as desktop environments (#2, #13, #20, #30, #32, #34, #35)
5ecddad 992d1b3 feat: pull requests for repositories in a workspace folder (#19, #22, #23)
ee02861 28648f0 fix(desktop): brand preview builds Beta at runtime, as they are packaged (#33)

#40 (the sync PR is marked merged when main moves) is folded into the setup commit, which already owns agents/dev/instructions.md.

Upstream CI

Upstream changed ci.yml: each job now gets id-token: write and passes T3_TASK_CACHE_READ_TOKEN to setup-ci-deps. fork-ci.yml was regenerated from the new ci.yml with the edits listed in its header reapplied. The fork-specific diff between the two files is the same before and after. The fork has no task-cache secret, so setup-ci-deps skips the remote cache, which its inputs.task-cache-read-token != '' guard handles. release.yml changed only in its test jobs' setup step. build_bundle and release-desktop.yml are unchanged, so fork-release.yml still fits them.

Conflicts

Setup commit. No conflicts apart from fork-ci.yml, described above.

#5, PR watch marker.

  • packages/provider-core/src/server/runtimeInstructions.ts and apps/server/src/mcp/toolkits/pullRequests/tools.ts: upstream cut the agent instructions and tool copy by 60% (upstream#24850). I added the fork's <!-- t3code:agent --> rule back to upstream's shorter text as one sentence each, instead of restoring the old wording.
  • The 8 Muse replay transcripts carry those instructions in their recorded requests. They now take upstream's text plus that same sentence, verbatim, in every recorded request (checked by string match).
  • pullRequestWatch.ts: kept byAgent and draft hold-back, with upstream's shorter comments.
  • gitHubPullRequestJson.ts: kept the fork's newest() helper, which adds review submittedAt to the fingerprint.
  • pullRequestWatch.forgejoComments.test.ts: upstream deleted it and moved its assertions into pullRequestWatch.test.ts (upstream#24000). The fork's changes went into the moved test: own in remarks changes, and a marked same-account remark is suppressed.

Sandboxes.

  • apps/web/src/components/sidebar/mainAppLocation.ts: upstream made Settings and Usage modals over the thread (upstream#24847). /sandboxes stays a sidebar utility page, beside Projects and Pull Requests.
  • routeTree.gen.ts was regenerated.

Workspace-folder pull requests. This is the large one. Upstream's forge refactor (upstream#23992 to upstream#24288, plus upstream#23999, upstream#24738 and upstream#22634) moved repository helpers out of @t3tools/shared/sourceControl into per-forge registries (sourceControlDefinitions on the server, sourceControlClients on clients). The fork code now uses those:

  • projectRepositorySelectors moved from packages/shared/src/sourceControl.ts to packages/client-runtime/src/sourceControlClients.ts, and its test moved with it. Web and mobile import it from there.
  • Removed helpers. sourceControlRepositorySelector, canonicalRepositoryKey and pullRequestHostOf are now the registry's repositorySelector, canonicalRepositoryKey and changeRequestHost. That covers the web assignment helper, the mobile environment fallback, MCP handlers.ts and PullRequestService.ts.
  • findProjectForChangeRequest / findProjectOnChangeRequestHost moved from the web app to client-runtime/changeRequestLinks.ts, so mobile now uses them too (upstream#24738). The fork's matching against a folder's workspaceRepositories is ported there, and its test went to changeRequestLinks.test.ts.
  • pullRequestLinkInput.ts: the fork's list of repositories and repo#123 handling are combined with upstream's per-forge terminology and GitCafe URL canonicalization. Each link repository now carries its forge's term, so the "no known URL" error says "merge request" on GitLab.
  • Mobile Link dialog: keeps upstream's forge term and the fork's repo#123 prompt for folders with several repositories.
  • composerPullRequestKey: legacyThreadPullRequestKey has left packages/shared, so a hostless exact-number entry now takes its host from its URL (new URL(url).host) instead. The only difference is for legacy Azure visualstudio.com URLs. Such an entry no longer merges with its linked snapshot, so it can show twice in # suggestions.
  • shellReducer.ts: the fork's workspaceRepositories comparison is combined with upstream's change that returns the previous snapshot when nothing changed.
  • orchestration-v2/http.ts: kept ProjectEnrichmentService.withEnrichment, with upstream's comment.
  • MCP handlers.ts and its test: kept upstream's GitCafe test and the fork's workspace-folder test. resolveTarget uses the checkout's identity with upstream's forge-recognition logic.
  • Docs and tests: docs/user/source-control.md keeps both upstream's line about gh pr create / glab mr create and the fork's repo#123 sentence. Test helpers follow upstream's renames (fakePullRequestProvider, branded SourceControlProviderKind).

#33, preview branding. updateChannels.ts: upstream only shortened the comment the fork removes, so I kept the fork's version.

Comment cap. Upstream's new lint rule caps comment blocks at four lines (upstream#21514). Eight fork comments went over. Each one was cut to its constraint in the commit that owns it: two in install-desktop.ts and one in seed-sandbox-server.ts (setup); catalog.ts, sandboxConnections.ts, useSandboxes.ts and sandboxSettingsSeed.ts (Sandboxes); and RepositoryIdentityResolver.ts (workspace folders).

Checks

  • vp run check:changed v0.1.3-nightly.20261025.3669 passes over every file the stack touches: format, lint, knip, graphql, typecheck (34 packages) and tests (57 files, 1100 tests). The only remaining lint warnings are in upstream code.

  • Typecheck of each stack commit passes on its own, run in a scratch worktree.

  • MuseReplayFixtures.integration.test.ts: 8/8.

  • Per-area tests also run:

    Area Files Tests
    apps/server (PR service, identity resolver, enrichment, MCP handlers, project service, PR watch, runtime layer) 8 all pass
    packages/client-runtime 131 1302
    packages/shared 995
    packages/source-control-github 284
    apps/web pull-request and composer 233
    mobile fallback and queries 7
    desktop updateChannels 2
  • Sandbox GITHUB_TOKEN: one GitHub credentials test fails only when the sandbox's GITHUB_TOKEN is in the environment. I ran with it unset, as CI does.

  • Fork-only files (scripts/fork, .github/workflows/fork-*.yml, Makefile, README.md, agents) match main, apart from fork-ci.yml and the comment trims above.

  • Upstream features against the fork's: upstream#24813 links PRs that agents create from the shell. It works from URLs, so it covers workspace folders. Settlement's branch-PR matching against a single repository identity predates this sync.

git diff origin/main sync/2026-10-25 is the new base plus the adaptations above. No developer-requested changes.

Range-diff

git range-diff 27e0c684c5..d4dbb5e29b 5f64c5eb29..28648f0a9c -- . ':!*.ndjson' is about 94 KB, too much for this description, so it follows in comments, one per commit. The Muse transcripts are left out: they differ only by the instruction text above.

Release build and live test

Fork release run 40222450900 built 0.1.3-preview.20261025.30 from 28648f0, without publishing. All six desktop targets built, and both Windows Verify jobs passed. Fork CI and Fork guard are green on this branch.

A Codex gpt-6.1-sol agent (xhigh reasoning, priority tier) live-tested that build against a nested Sandbox, qa-sync-20261025, created from this branch's agents/dev nested variant. Everything passed:

Check Result
Create the Sandbox and install the build's server Pass. Running and connected, project present, server reports t3 v0.1.3-preview.20261025.30
hostname from Codex (GPT-6-Luna, Low) and Claude (Haiku 5.5, Low) Pass. Both returned qa-sync-20261025
Stop, Start, Delete Pass. Reconnected after Start and hostname still matched. Delete removed the Sandbox, its project and threads; agentctl reports no resources
Sandboxes page with upstream's Settings and Usage modals Pass. Sandboxes stays a full page; Settings and Usage open over it and close back to it; sidebar Back returns to the thread
Pull requests in the Sandbox's workspace folder Pass. The Pull Requests page lists #41 for the Sandbox's checkout; bare 41 links (then unlinked); a pasted PR URL opens in the app
<!-- t3code:agent --> rule reaches agents Pass. Claude quoted it from its instructions
Connected Codex hostname Claude hostname
Sandbox connected Codex hostname Claude hostname
After Stop and Start Deleted Settings over Sandboxes
Hostname after restart After delete Settings from Sandboxes
Workspace-folder PRs Link by bare number Marker instruction
PR list Link bare number Marker

Oddities during startup. All three recovered on their own, and none is a confirmed regression:

  • The first automatic connect tried to download the unpublished version before seeding and showed a 404, which is expected when testing an unpublished build.
  • One setup attempt reported codex --version did not finish within 5s while the nested VM was under a cold-start load of 5.7. The retry succeeded.
  • Right after Start, the app briefly showed "T3 Code on qa-sync-20261025 did not answer" while it reconnected.

Not covered:

  • repo#123 disambiguation: the Sandbox held a single checkout.
  • Watch wakes on PR comments.
  • Packaged Beta branding: the dev desktop isn't a preview build.

Rewritten by Claude Opus 5.5 in Claude Code.

@martinothamar

Copy link
Copy Markdown
Owner Author

Range-diff, commit 1: 1: 2b44d95803 ! 1: 2709f97c41 chore(fork): set up development, releases and maintenance (#1, #3, #4, #6, #9, #15, #16, #18, #21, #27, #28, #29, #38)

    @@ Metadata
     Author: Martin Othamar <martin@othamar.net>
     
      ## Commit message ##
    -    chore(fork): set up development, releases and maintenance (#1, #3, #4, #6, #9, #15, #16, #18, #21, #27, #28, #29, #38)
    +    chore(fork): set up development, releases and maintenance (#1, #3, #4, #6, #9, #15, #16, #18, #21, #27, #28, #29, #38, #40)
     
         Everything that exists because this is a fork rather than upstream.
     
    @@ .github/workflows/fork-ci.yml (new)
     +  # file, docs included. The `Check` job at the end of this file passes only
     +  # when every other job passes or was skipped by `changes`.
     +  lint:
    ++    permissions:
    ++      contents: read
    ++      id-token: write
     +    name: Lint
     +    runs-on: ubuntu-24.04
     +    timeout-minutes: 10
    @@ .github/workflows/fork-ci.yml (new)
     +
     +      - name: Setup Vite+
     +        uses: ./.github/actions/setup-ci-deps
    ++        with:
    ++          task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}
     +
     +      # electron's install.js (run by ensure:electron) reads this cache before it
     +      # downloads from GitHub. The key changes only with the Electron version.
    @@ .github/workflows/fork-ci.yml (new)
     +        run: vp check
     +
     +  build:
    ++    permissions:
    ++      contents: read
    ++      id-token: write
     +    name: Build and Typecheck
     +    needs: changes
     +    if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }}
    @@ .github/workflows/fork-ci.yml (new)
     +
     +      - name: Setup Vite+
     +        uses: ./.github/actions/setup-ci-deps
    ++        with:
    ++          task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}
     +
     +      # electron's install.js (run by ensure:electron) reads this cache before it
     +      # downloads from GitHub. The key changes only with the Electron version.
    @@ .github/workflows/fork-ci.yml (new)
     +  # dependency layers. The concurrency limit stays at the default 4 so peak load
     +  # per runner is unchanged.
     +  test:
    ++    permissions:
    ++      contents: read
    ++      id-token: write
     +    name: Test
     +    needs: changes
     +    if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }}
    @@ .github/workflows/fork-ci.yml (new)
     +
     +      - name: Setup Vite+
     +        uses: ./.github/actions/setup-ci-deps
    ++        with:
    ++          task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}
     +
     +      # electron's install.js (run by ensure:electron) reads this cache before it
     +      # downloads from GitHub. The key changes only with the Electron version.
    @@ .github/workflows/fork-ci.yml (new)
     +  # started last and ran alone for about 40s after everything else finished.
     +  # release.yml mirrors this test, test_web, and test_server split.
     +  test_web:
    ++    permissions:
    ++      contents: read
    ++      id-token: write
     +    name: Test Web
     +    needs: changes
     +    if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }}
    @@ .github/workflows/fork-ci.yml (new)
     +
     +      - name: Setup Vite+
     +        uses: ./.github/actions/setup-ci-deps
    ++        with:
    ++          task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}
     +
     +      # The browser project runs React tests in headless Chromium. Same cache key as
     +      # test_server, so both jobs share one downloaded browser.
    @@ .github/workflows/fork-ci.yml (new)
     +  # The shards are the longest jobs, so they gate themselves instead of waiting
     +  # ~15s for `changes`: a shard the change cannot affect stops after checkout.
     +  test_server:
    ++    permissions:
    ++      contents: read
    ++      id-token: write
     +    name: Test Server ${{ matrix.shard }}
     +    runs-on: ubuntu-24.04
     +    timeout-minutes: 10
    @@ .github/workflows/fork-ci.yml (new)
     +      - name: Setup Vite+
     +        if: steps.changes.outputs.test_server != 'false'
     +        uses: ./.github/actions/setup-ci-deps
    ++        with:
    ++          task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}
     +
     +      # No Electron setup here: `t3` (apps/server) has no Electron dependency
     +      # and none of its tests touch the runtime. Only the non-server `test`
    @@ .github/workflows/fork-ci.yml (new)
     +          done
     +
     +  release_smoke:
    ++    permissions:
    ++      contents: read
    ++      id-token: write
     +    name: Release Smoke
     +    needs: changes
     +    if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }}
    @@ .github/workflows/fork-ci.yml (new)
     +
     +      - name: Setup Vite+
     +        uses: ./.github/actions/setup-ci-deps
    ++        with:
    ++          task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}
     +
     +      - name: Exercise release-only workflow steps
     +        run: node scripts/release-smoke.ts
    @@ agents/dev/instructions.md (new)
     +6. `gh workflow run fork-release.yml --ref sync/<date>` builds the apps and server archives
     +   without publishing them. Test that build as below and post the results on the pull request.
     +7. Once the developer approves, tag the old tip `archive/main-<date>` and push the tag, then
    -+   `git push --force-with-lease=main:<old tip> origin sync/<date>:main` and close the pull
    -+   request. Then `gh workflow run fork-release.yml --ref main -f from_run=<run id>` publishes the
    -+   tested build as the release. If the stack changed since that run, build and test again first;
    -+   the workflow refuses a run of another commit.
    ++   `git push --force-with-lease=main:<old tip> origin sync/<date>:main`. GitHub then marks the
    ++   pull request merged, since its head is `main`'s tip, so it needs no closing; comment that the
    ++   rewrite is applied. Then `gh workflow run fork-release.yml --ref main -f from_run=<run id>`
    ++   publishes the tested build as the release. If the stack changed since that run, build and test
    ++   again first; the workflow refuses a run of another commit.
     +8. Move the primary checkout with `git checkout -B main origin/main`, and each branch from step 1
     +   with `git rebase --onto origin/main archive/main-<date> <branch>`.
     +
    @@ scripts/fork/install-desktop.ts (new)
     @@
     +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalFetch:off globalDate:off - runs before `vp i`, so only Node built-ins exist.
     +/**
    -+ * Installs the fork's desktop app for the current user, on Linux, macOS and
    -+ * Windows, from a release of the fork (.github/workflows/fork-release.yml):
    -+ *
    -+ *   node scripts/fork/install-desktop.ts [--version <version>]
    -+ *   node scripts/fork/install-desktop.ts --from-source
    -+ *
    -+ * By default it downloads the latest release, or the given one, and checks it
    -+ * against the release's SHA256SUMS. With --from-source it builds the
    -+ * checked-out commit instead, to try an install before releasing it. SSH hosts
    -+ * install the server release matching the app's version, so the build takes
    -+ * the version of the release published from that commit, if there is one.
    ++ * `node scripts/fork/install-desktop.ts [--version <version> | --from-source]` installs a fork
    ++ * release checked against its SHA256SUMS, or builds this commit. SSH hosts install the server
    ++ * matching the app's version, so a source build takes the version of its commit's release.
     + * See docs/operations/fork-desktop-build.md.
     + */
     +import * as NodeChildProcess from "node:child_process";
    @@ scripts/fork/install-desktop.ts (new)
     +  win32: { script: "dist:desktop:win", extension: ".exe", arches: { x64: "x64", arm64: "arm64" } },
     +};
     +
    -+// Of the build's own T3CODE_DESKTOP_* options only the version is passed on;
    -+// others move its output, skip it or add an update feed. CARGO_TARGET_DIR moves
    -+// cargo output away from the path the build copies native helpers from.
    -+// GITHUB_REPOSITORY gives the build an update feed, and the app would then
    -+// replace itself with an upstream build. Without a feed, a publish token makes
    -+// electron-builder fall back to a publish provider it cannot resolve, and the
    -+// build fails writing update info.
    ++// Only the version of T3CODE_DESKTOP_* passes; the rest move output or add a feed. CARGO_TARGET_DIR
    ++// hides the native helpers the build copies. GITHUB_REPOSITORY adds an update feed that would
    ++// replace the app with upstream's, and without a feed a publish token fails electron-builder.
     +const REMOVED_BUILD_ENV = new Set([
     +  "CARGO_TARGET_DIR",
     +  "GITHUB_REPOSITORY",
    @@ scripts/fork/seed-sandbox-server.ts (new)
     @@
     +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off - a maintainer script with only Node built-ins, like install-desktop.ts.
     +/**
    -+ * Installs the T3 server a Fork release run built into an agentctl Sandbox, so
    -+ * a desktop app can connect to it before that build is released:
    -+ *
    -+ *   node scripts/fork/seed-sandbox-server.ts --run <run id> [--agent <name>]
    -+ *
    -+ * A run of .github/workflows/fork-release.yml on a sync branch leaves its server
    -+ * archives as artifacts. This downloads the one for the Sandbox's platform and
    -+ * unpacks it where the SSH installer (packages/ssh/src/tunnel.ts) keeps a
    -+ * runtime, so the app finds it installed and downloads nothing. Without
    -+ * --agent it only prints the run's version, to start the app with as
    -+ * T3CODE_REMOTE_SERVER_VERSION before it creates the Sandbox. Needs `gh` and the
    -+ * `agentctl-<name>` SSH alias from `agentctl ssh-config install`.
    -+ * See docs/operations/fork-desktop-build.md.
    ++ * `node scripts/fork/seed-sandbox-server.ts --run <run id> [--agent <name>]` unpacks a Fork release
    ++ * run's server archive where packages/ssh/src/tunnel.ts keeps runtimes, so the app downloads
    ++ * nothing. Without --agent it prints the version for T3CODE_REMOTE_SERVER_VERSION. Needs `gh` and
    ++ * the `agentctl-<name>` alias. See docs/operations/fork-desktop-build.md.
     + */
     +import * as NodeChildProcess from "node:child_process";
     +import * as NodeFS from "node:fs";

@martinothamar

Copy link
Copy Markdown
Owner Author

Range-diff, commit 2: 2: 3aec799068 ! 2: b24a049700 fix(server): wake watched pull requests for users sharing the agent account (#5)

    @@ apps/server/src/mcp/toolkits/pullRequests/tools.ts: const ListThreadPullRequests
      
      const WatchPullRequestTool = Tool.make("watch_pull_request", {
        description:
    --    "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch. A subagent cannot watch: its parent thread owns the pull request.",
    -+    "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. End every comment or review you post on the pull request with <!-- t3code:agent --> on its own last line so your own replies do not wake you; the user may share your account. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch. A subagent cannot watch: its parent thread owns the pull request.",
    +-    "Watch an open PR for this thread, linking it first if needed. T3 wakes you on failed checks, passed required checks, new comments or reviews, or conflicts. Handle old comments first. Only later ones wake you. After this call, end your turn. The watch stays on after each wake, so do not call this again on a wake. A wake is not a merge decision. Check readiness yourself.",
    ++    "Watch an open PR for this thread, linking it first if needed. T3 wakes you on failed checks, passed required checks, new comments or reviews, or conflicts. Handle old comments first. Only later ones wake you. End each comment or review you post with <!-- t3code:agent --> on its own last line, so your replies do not wake you. The user may share your account. After this call, end your turn. The watch stays on after each wake, so do not call this again on a wake. A wake is not a merge decision. Check readiness yourself.",
        parameters: PullRequestTargetInput,
        success: WatchPullRequestResult,
        failure: PullRequestToolError,
     
    - ## apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts ##
    -@@ apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts: describe("Forgejo comment watch boundary", () => {
    -     assert.strictEqual(finalComment.editedAt, "2026-10-10T20:04:50.000Z");
    -     assert.isUndefined(decodedComment(createdAt).editedAt);
    -     const completed = evaluatePullRequestWatch(seen, finalDetail, [finalComment]);
    --    assert.deepEqual(completed.changes, [{ kind: "remarks", remarks: [finalComment] }]);
    -+    assert.deepEqual(completed.changes, [
    -+      { kind: "remarks", remarks: [finalComment], own: "agent-user" },
    -+    ]);
    -     assert.strictEqual(completed.next.remarksThrough, "2026-10-10T20:04:50.000Z");
    -     assert.deepEqual(
    -       evaluatePullRequestWatch(completed.next, finalDetail, [finalComment]).changes,
    -@@ apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts: describe("Forgejo comment watch boundary", () => {
    -     );
    -     const secondEdit = decodedComment("2026-10-10T20:05:50Z");
    -     const second = evaluatePullRequestWatch(completed.next, finalDetail, [secondEdit]);
    --    assert.deepEqual(second.changes, [{ kind: "remarks", remarks: [secondEdit] }]);
    -+    assert.deepEqual(second.changes, [
    -+      { kind: "remarks", remarks: [secondEdit], own: "agent-user" },
    -+    ]);
    -     assert.deepEqual(evaluatePullRequestWatch(second.next, finalDetail, [secondEdit]).changes, []);
    -     assert.deepEqual(
    -       evaluatePullRequestWatch(seen, finalDetail, [
    --        { ...finalComment, author: { login: "agent-user", name: null, avatarUrl: null } },
    -+        {
    -+          ...finalComment,
    -+          author: { login: "agent-user", name: null, avatarUrl: null },
    -+          body: `${finalComment.body}\n<!-- t3code:agent -->`,
    -+        },
    -       ]).changes,
    -       [],
    -     );
    -
      ## apps/server/src/orchestration-v2/pullRequestWatch.test.ts ##
     @@ apps/server/src/orchestration-v2/pullRequestWatch.test.ts: import { assert, describe, it } from "@effect/vitest";
      import {
    @@ apps/server/src/orchestration-v2/pullRequestWatch.test.ts: describe("evaluatePul
          ]);
        });
      
    +@@ apps/server/src/orchestration-v2/pullRequestWatch.test.ts: describe("evaluatePullRequestWatch", () => {
    +     assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [seen]).changes, []);
    +     const edited = { ...seen, editedAt: "2026-10-10T20:04:50.000Z" };
    +     const first = evaluatePullRequestWatch(watching, detail(), [edited]);
    +-    assert.deepEqual(first.changes, [{ kind: "remarks", remarks: [edited] }]);
    ++    assert.deepEqual(first.changes, [{ kind: "remarks", remarks: [edited], own: "agent-user" }]);
    +     assert.strictEqual(first.next.remarksThrough, edited.editedAt);
    +     assert.deepEqual(evaluatePullRequestWatch(first.next, detail(), [edited]).changes, []);
    +     const again = { ...seen, editedAt: "2026-10-10T20:05:50.000Z" };
    +     const second = evaluatePullRequestWatch(first.next, detail(), [again]);
    +-    assert.deepEqual(second.changes, [{ kind: "remarks", remarks: [again] }]);
    ++    assert.deepEqual(second.changes, [{ kind: "remarks", remarks: [again], own: "agent-user" }]);
    +     assert.deepEqual(evaluatePullRequestWatch(second.next, detail(), [again]).changes, []);
    +-    const own = { ...edited, author: { login: "agent-user", name: null, avatarUrl: null } };
    ++    const own = {
    ++      ...edited,
    ++      author: { login: "agent-user", name: null, avatarUrl: null },
    ++      body: `${edited.body}\n<!-- t3code:agent -->`,
    ++    };
    +     assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [own]).changes, []);
    +   });
    + 
     @@ apps/server/src/orchestration-v2/pullRequestWatch.test.ts: describe("evaluatePullRequestWatch", () => {
          const contributor = detail({ author: { login: "contributor", name: null, avatarUrl: null } });
          const reply = remark("contributor", "2026-10-02T12:06:00Z");
    @@ apps/server/src/orchestration-v2/pullRequestWatch.test.ts: describe("evaluatePul
          const conflicting = detail({ mergeability: "conflicting" });
          const first = evaluatePullRequestWatch(watch(), conflicting, noRemarks);
     @@ apps/server/src/orchestration-v2/pullRequestWatch.test.ts: describe("evaluatePullRequestWatch", () => {
    -     assert.isTrue(anHourLater(watch(), azure).every((kinds) => kinds.length === 0));
    +     assert.isTrue(anHourLater(watch(), silent).every((kinds) => kinds.length === 0));
        });
      });
     +
    @@ apps/server/src/orchestration-v2/pullRequestWatch.test.ts: describe("evaluatePul
     +});
     
      ## apps/server/src/orchestration-v2/pullRequestWatch.ts ##
    -@@ apps/server/src/orchestration-v2/pullRequestWatch.ts: import * as DateTime from "effect/DateTime";
    +@@ apps/server/src/orchestration-v2/pullRequestWatch.ts: import type {
    + } from "@t3tools/contracts";
    + import * as DateTime from "effect/DateTime";
      
    - /**
    -  * Wakes in a row that bring only comments. Check, conflict, or push news resets the count, so
    -- * this only stops a chatty bot looping an agent that is replying to it.
    -+ * this stops a chatty bot, or the agent's own unmarked replies, from looping it.
    -  */
    +-/** Bounds consecutive comment-only wakes to stop bot reply loops; check, conflict, or push news resets it. */
    ++/**
    ++ * Bounds consecutive comment-only wakes, so a bot or the agent's own unmarked replies cannot loop
    ++ * it. Check, conflict, or push news resets it.
    ++ */
      export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10;
    - /** How long a GitHub head may go without any check before the agent is told none reported. */
    + /** How long a head may go without any check before the agent is told none reported. */
      const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000;
      const LISTED_ITEMS = 10;
      const SNIPPET_LENGTH = 200;
    @@ apps/server/src/orchestration-v2/pullRequestWatch.ts: export function evaluatePu
        const own = (detail.viewer ?? detail.author?.login)?.toLowerCase();
        const through = Date.parse(watch.remarksThrough);
        const activeAt = (remark: PullRequestComment) => remark.editedAt ?? remark.createdAt;
    --  // GitHub times are per second, so remarks at the boundary time are told apart by ID.
    +-  // Hosts can stamp remarks to the second, so remarks at the boundary time are told apart by ID.
     +  const byAgent = (remark: PullRequestComment) =>
     +    remark.author?.login.toLowerCase() === own && AGENT_MARKER.test(remark.body);
    -+  // GitHub times are per second, so remarks at the boundary time are told apart by ID. A draft
    -+  // is held back until its review is submitted, which gives it a new time.
    ++  // Hosts can stamp remarks to the second, so remarks at the boundary time are told apart by ID. A
    ++  // draft is held back until its review is submitted, which gives it a new time.
        const fresh = (remarks ?? []).filter((remark) => {
          const at = Date.parse(activeAt(remark));
          return (
    @@ packages/contracts/src/pullRequest.ts: export const PullRequestThreadComment = S
      ## packages/provider-core/src/server/runtimeInstructions.ts ##
     @@
      const PULL_REQUEST_LINKING_INSTRUCTIONS = `<pull_request_linking>
    --When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.
    -+When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. End every comment, review, or review reply you post on a pull request with the hidden marker <!-- t3code:agent --> on its own last line: you may share a GitHub account with the user, and the marker is how T3 Code tells your replies from theirs. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.
    +-Link each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR <full URL>, head <SHA>, checks and reviews <state>. Links and watches persist across pushes. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.
    ++Link each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR <full URL>, head <SHA>, checks and reviews <state>. Links and watches persist across pushes. End every PR comment, review, or reply you post with the hidden marker <!-- t3code:agent --> on its own last line: the user may share your GitHub account, and the marker tells your replies from theirs. Never wait with sleep, loops, gh --watch, Monitor, or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.
      </pull_request_linking>`;
      
    - /**
    + /** Omit model and effort when the harness manages them dynamically. */
     
      ## packages/source-control-github/schema/github.graphql.ts ##
     @@ packages/source-control-github/schema/github.graphql.ts: export type PullRequestBranchUpdateMethod = typeof PullRequestBranchUpdateMethod
    @@ packages/source-control-github/src/server/gitHubPullRequestJson.ts: export inter
     -type EditedConnection = WatchedPullRequest["comments"] | WatchedPullRequest["reviews"];
      type StateCounts = ReadonlyArray<{ readonly state: string; readonly count: number }> | null;
      
    --// An edit only ever moves `lastEditedAt` forward, so the newest one stands for them all.
     -const newestEdit = (connection: EditedConnection) =>
     -  (connection?.nodes ?? []).reduce(
     -    (newest, node) =>

@martinothamar

Copy link
Copy Markdown
Owner Author

Range-diff, commit 3: 3: 272c27e7f4 ! 3: f0990bfa03 feat: agentctl Sandboxes as desktop environments (#2, #13, #20, #30, #32, #34, #35)

    @@ apps/web/src/components/sidebar/SidebarChrome.tsx
      import { cn } from "../../lib/utils";
      import { usePullRequestsSupported } from "../../state/environments";
      import { T3Wordmark } from "../T3Wordmark";
    -@@ apps/web/src/components/sidebar/SidebarChrome.tsx: export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
    +@@ apps/web/src/components/sidebar/SidebarChrome.tsx: const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
                  label="Usage"
                  onClick={handleUsageClick}
                />
    @@ apps/web/src/components/sidebar/SidebarThreadHeader.tsx: export function Sidebar
              <SidebarHeaderIconButton
     
      ## apps/web/src/components/sidebar/mainAppLocation.ts ##
    -@@ apps/web/src/components/sidebar/mainAppLocation.ts: export function isSidebarUtilityPage(pathname: string) {
    -     pathname.startsWith("/settings/") ||
    -     pathname.startsWith("/projects/") ||
    -     pathname === "/usage" ||
    -+    pathname === "/sandboxes" ||
    -     pathname === "/pull-requests"
    -   );
    +@@ apps/web/src/components/sidebar/mainAppLocation.ts: export function isAppModalPage(pathname: string) {
    + }
    + 
    + export function isSidebarUtilityPage(pathname: string) {
    +-  return pathname.startsWith("/projects/") || pathname === "/pull-requests";
    ++  return (
    ++    pathname.startsWith("/projects/") || pathname === "/sandboxes" || pathname === "/pull-requests"
    ++  );
      }
    + 
    + let mainAppHref: string | null = null;
     
      ## apps/web/src/components/ui/combobox.tsx ##
     @@ apps/web/src/components/ui/combobox.tsx: function ComboboxItem({
    @@ apps/web/src/connection/platform.ts: const layerPlatformConnectionSource = Layer
          }).pipe(Effect.provide(FetchHttpClient.layer));
     
      ## apps/web/src/routeTree.gen.ts ##
    -@@ apps/web/src/routeTree.gen.ts: import { Route as rootRouteImport } from './routes/__root'
    +@@
    + 
    + import { Route as rootRouteImport } from './routes/__root'
      import { Route as WelcomeRouteImport } from './routes/welcome'
    - import { Route as UsageRouteImport } from './routes/usage'
    - import { Route as SettingsRouteImport } from './routes/settings'
     +import { Route as SandboxesRouteImport } from './routes/sandboxes'
      import { Route as PairRouteImport } from './routes/pair'
    + import { Route as GuestRouteImport } from './routes/guest'
      import { Route as ConnectAgentRouteImport } from './routes/connect-agent'
    - import { Route as ConnectRouteImport } from './routes/connect'
    -@@ apps/web/src/routeTree.gen.ts: const SettingsRoute = SettingsRouteImport.update({
    -   path: '/settings',
    +@@ apps/web/src/routeTree.gen.ts: const WelcomeRoute = WelcomeRouteImport.update({
    +   path: '/welcome',
        getParentRoute: () => rootRouteImport,
      } as any)
     +const SandboxesRoute = SandboxesRouteImport.update({
    @@ apps/web/src/routeTree.gen.ts: const SettingsRoute = SettingsRouteImport.update(
        id: '/pair',
        path: '/pair',
     @@ apps/web/src/routeTree.gen.ts: export interface FileRoutesByFullPath {
    -   '/connect': typeof ConnectRoute
        '/connect-agent': typeof ConnectAgentRoute
    +   '/guest': typeof GuestRoute
        '/pair': typeof PairRoute
     +  '/sandboxes': typeof SandboxesRoute
    -   '/settings': typeof SettingsRouteWithChildren
    -   '/usage': typeof UsageRoute
        '/welcome': typeof WelcomeRoute
    +   '/new': typeof ChatNewRoute
    +   '/pull-requests': typeof ChatPullRequestsRoute
     @@ apps/web/src/routeTree.gen.ts: export interface FileRoutesByTo {
    -   '/connect': typeof ConnectRoute
        '/connect-agent': typeof ConnectAgentRoute
    +   '/guest': typeof GuestRoute
        '/pair': typeof PairRoute
     +  '/sandboxes': typeof SandboxesRoute
    -   '/settings': typeof SettingsRouteWithChildren
    -   '/usage': typeof UsageRoute
        '/welcome': typeof WelcomeRoute
    +   '/new': typeof ChatNewRoute
    +   '/pull-requests': typeof ChatPullRequestsRoute
     @@ apps/web/src/routeTree.gen.ts: export interface FileRoutesById {
    -   '/connect': typeof ConnectRoute
        '/connect-agent': typeof ConnectAgentRoute
    +   '/guest': typeof GuestRoute
        '/pair': typeof PairRoute
     +  '/sandboxes': typeof SandboxesRoute
    -   '/settings': typeof SettingsRouteWithChildren
    -   '/usage': typeof UsageRoute
        '/welcome': typeof WelcomeRoute
    +   '/_chat/new': typeof ChatNewRoute
    +   '/_chat/pull-requests': typeof ChatPullRequestsRoute
     @@ apps/web/src/routeTree.gen.ts: export interface FileRouteTypes {
    -     | '/connect'
          | '/connect-agent'
    +     | '/guest'
          | '/pair'
     +    | '/sandboxes'
    -     | '/settings'
    -     | '/usage'
          | '/welcome'
    +     | '/new'
    +     | '/pull-requests'
     @@ apps/web/src/routeTree.gen.ts: export interface FileRouteTypes {
    -     | '/connect'
          | '/connect-agent'
    +     | '/guest'
          | '/pair'
     +    | '/sandboxes'
    -     | '/settings'
    -     | '/usage'
          | '/welcome'
    +     | '/new'
    +     | '/pull-requests'
     @@ apps/web/src/routeTree.gen.ts: export interface FileRouteTypes {
    -     | '/connect'
          | '/connect-agent'
    +     | '/guest'
          | '/pair'
     +    | '/sandboxes'
    -     | '/settings'
    -     | '/usage'
          | '/welcome'
    +     | '/_chat/new'
    +     | '/_chat/pull-requests'
     @@ apps/web/src/routeTree.gen.ts: export interface RootRouteChildren {
    -   ConnectRoute: typeof ConnectRoute
        ConnectAgentRoute: typeof ConnectAgentRoute
    +   GuestRoute: typeof GuestRoute
        PairRoute: typeof PairRoute
     +  SandboxesRoute: typeof SandboxesRoute
    -   SettingsRoute: typeof SettingsRouteWithChildren
    -   UsageRoute: typeof UsageRoute
        WelcomeRoute: typeof WelcomeRoute
    +   ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute
    + }
     @@ apps/web/src/routeTree.gen.ts: declare module '@tanstack/react-router' {
    -       preLoaderRoute: typeof SettingsRouteImport
    +       preLoaderRoute: typeof WelcomeRouteImport
            parentRoute: typeof rootRouteImport
          }
     +    '/sandboxes': {
    @@ apps/web/src/routeTree.gen.ts: declare module '@tanstack/react-router' {
            id: '/pair'
            path: '/pair'
     @@ apps/web/src/routeTree.gen.ts: const rootRouteChildren: RootRouteChildren = {
    -   ConnectRoute: ConnectRoute,
        ConnectAgentRoute: ConnectAgentRoute,
    +   GuestRoute: GuestRoute,
        PairRoute: PairRoute,
     +  SandboxesRoute: SandboxesRoute,
    -   SettingsRoute: SettingsRouteWithChildren,
    -   UsageRoute: UsageRoute,
        WelcomeRoute: WelcomeRoute,
    +   ProjectsProjectKeyRoute: ProjectsProjectKeyRoute,
    + }
     
      ## apps/web/src/routes/__root.tsx ##
     @@ apps/web/src/routes/__root.tsx: import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall
    @@ apps/web/src/sandboxes/sandboxConnections.ts (new)
     +  sandbox.action === "deleting" || sandbox.phase === "terminating";
     +
     +/**
    -+ * Every Sandbox T3 has reached once is an environment, whether or not it is
    -+ * running now: like a saved SSH host, a stopped one stays listed and simply
    -+ * fails to connect until it is started. A Sandbox being deleted loses its
    -+ * registration right away, so nothing reconnects to it while agentctl removes
    -+ * it; a recreated one loses its environment id upstream.
    ++ * Every Sandbox T3 has reached is an environment: like a saved SSH host, a stopped one stays listed
    ++ * and fails to connect until started. One being deleted loses its registration at once, so nothing
    ++ * reconnects while agentctl removes it; a recreated one loses its environment id upstream.
     + */
     +export function sandboxRegistrations(
     +  sandboxes: ReadonlyArray<AgentctlSandbox>,
    @@ apps/web/src/sandboxes/sandboxSettingsSeed.ts (new)
     +}
     +
     +/**
    -+ * The settings a new Sandbox should start with: each project-scoped key the
    -+ * user changed on this computer while the Sandbox still has the default.
    -+ * A Sandbox's own choices are never overwritten. Model selections are only
    -+ * copied when the Sandbox has that provider instance turned on.
    -+ *
    -+ * Pi is off until turned on, so a Sandbox whose Agent sets up agentctl's Pi
    -+ * harness gets it turned on, unless its settings already have a Pi entry.
    ++ * Each project-scoped key the user changed here while the new Sandbox has the default, never
    ++ * overwriting its own choices; model selections only for provider instances it has turned on. Pi
    ++ * is off until turned on, so an Agent with agentctl's Pi harness gets it unless it has a Pi entry.
     + */
     +export function sandboxSettingsSeed(input: {
     +  readonly local: ServerSettings;
    @@ apps/web/src/sandboxes/useSandboxes.ts (new)
     +
     +import { EMPTY_SANDBOX_SNAPSHOT, readAgentctlBridge } from "./sandboxConnections";
     +
    -+// The bridge has no change event, so one shared poller reads the desktop's
    -+// snapshot while anything is subscribed. Reads are a cheap synchronous IPC,
    -+// and rare without agentctl. Desktop main keeps the last Sandboxes when
    -+// agentctl fails, so a read never drops them.
    -+// Desktop main refreshes every 3 seconds, so reading more often finds nothing new.
    ++// The bridge has no change event, so one shared poller reads the desktop's snapshot (a cheap sync
    ++// IPC) while anything is subscribed. Desktop main keeps the last Sandboxes when agentctl fails, so
    ++// a read never drops them, and refreshes every 3 seconds, so reading more often finds nothing new.
     +const POLL_MS = 3_000;
     +const UNAVAILABLE_POLL_MS = 15_000;
     +
    @@ apps/web/src/sandboxes/useSandboxes.ts (new)
     
      ## docs/README.md ##
     @@
    - - [Product usage data](./user/telemetry.md)
      - [Remote access](./user/remote-access.md)
    + - [Guest links](./user/guest-links.md)
      - [T3 Connect troubleshooting](./user/t3-connect-troubleshooting.md)
     +- [agentctl Sandboxes](./user/agentctl-sandboxes.md)
      - [Outside agents (MCP)](./user/outside-agents.md)
    @@ packages/client-runtime/src/connection/catalog.ts: export class SshConnectionReg
      export const ConnectionRegistration = Schema.Union([
        RelayConnectionRegistration,
        BearerConnectionRegistration,
    -@@ packages/client-runtime/src/connection/catalog.ts: export type ConnectionRegistration = typeof ConnectionRegistration.Type;
    -  * The primary stays on same-origin cookie auth
    -  * (`PrimaryConnectionRegistration`); secondary local backends live on a
    -  * separate loopback origin and authenticate with a bearer token minted from
    +@@ packages/client-runtime/src/connection/catalog.ts: export const ConnectionRegistration = Schema.Union([
    + export type ConnectionRegistration = typeof ConnectionRegistration.Type;
    + 
    + /**
    +- * The primary stays on same-origin cookie auth
    +- * (`PrimaryConnectionRegistration`); secondary local backends live on a
    +- * separate loopback origin and authenticate with a bearer token minted from
     - * their bootstrap credential (`BearerConnectionRegistration`).
    -+ * their bootstrap credential (`BearerConnectionRegistration`). Hosts the
    -+ * desktop discovers over SSH, such as agentctl Sandboxes, connect like saved
    -+ * SSH environments (`PlatformSshConnectionRegistration`).
    ++ * The primary stays on same-origin cookie auth; secondary local backends live on a separate
    ++ * loopback origin with a bearer token minted from their bootstrap credential. Hosts the desktop
    ++ * discovers over SSH, such as agentctl Sandboxes, connect like saved SSH environments.
       */
      export const PlatformConnectionRegistration = Schema.Union([
        PrimaryConnectionRegistration,

@martinothamar

Copy link
Copy Markdown
Owner Author

Range-diff, commit 4: 4: 5ecddad159 ! 4: 992d1b3ad9 feat: pull requests for repositories in a workspace folder (#19, #22, #23)

    @@ apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts
     @@
      import type { EnvironmentProject } from "@t3tools/client-runtime/state/models";
     +import type { RepositoryIdentity } from "@t3tools/contracts";
    -+import {
    -+  canonicalRepositoryKey,
    -+  sourceControlRepositorySelector,
    -+} from "@t3tools/shared/sourceControl";
    ++import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
      
      export type PullRequestEnvironmentTarget = {
        readonly environmentId: string;
    @@ apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts: export
     -  const host = target.host ?? own?.repositoryIdentity?.canonicalKey.split("/")[0];
     +  const repository = target.repository.trim().toLowerCase();
     +  const matchesRepository = (identity: RepositoryIdentity) =>
    -+    sourceControlRepositorySelector(identity)?.toLowerCase() === repository ||
    -+    canonicalRepositoryKey(identity.canonicalKey.toLowerCase()).split("/").slice(1).join("/") ===
    -+      repository;
    ++    sourceControlClients.repositorySelector(identity)?.toLowerCase() === repository ||
    ++    sourceControlClients
    ++      .canonicalRepositoryKey(identity.canonicalKey.toLowerCase())
    ++      .split("/")
    ++      .slice(1)
    ++      .join("/") === repository;
     +  const hostOf = (identity: RepositoryIdentity) =>
    -+    canonicalRepositoryKey(identity.canonicalKey.toLowerCase()).split("/")[0];
    ++    sourceControlClients.canonicalRepositoryKey(identity.canonicalKey.toLowerCase()).split("/")[0];
     +  const hosts = new Set(
     +    own === undefined ? [] : repositoriesOf(own).filter(matchesRepository).map(hostOf),
     +  );
    @@ apps/mobile/src/features/threads/ThreadComposer.tsx
      import { useSidebarWorkspaceTabs } from "../workspace/useSystemWorkspaceTabBar";
      import { Button } from "../../components/ui/Button";
      import * as Dialog from "../../components/ui/Dialog";
    -+import { projectRepositorySelectors } from "@t3tools/shared/sourceControl";
    ++import { projectRepositorySelectors } from "@t3tools/client-runtime/source-control-clients";
      import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
      import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
      import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
    @@ apps/mobile/src/features/threads/useLinkedPullRequestActions.ts
      } from "@t3tools/client-runtime/pull-request-link-input";
      import type {
     @@ apps/mobile/src/features/threads/useLinkedPullRequestActions.ts: export function useLinkedPullRequestActions(input: {
    -   );
      
        const promptLink = useCallback(() => {
    +     const { singular } = terminology;
     +    const repositories = pullRequestLinkRepositories(project);
     +    // A folder holding several repositories names one with repo#123 instead of a bare number.
     +    const severalRepositories = repositories.length > 1;
          Dialog.prompt({
    -       title: "Link pull request",
    --      message: "Paste a pull request URL, or enter 123 / #123 for this thread's repository.",
    +       title: `Link ${singular}`,
    +-      message: `Paste a ${singular} URL, or enter 123 / #123 for this thread's repository.`,
     +      message: severalRepositories
    -+        ? "Paste a pull request URL, or enter repo#123 for one of the repositories in this thread's folder."
    -+        : "Paste a pull request URL, or enter 123 / #123 for this thread's repository.",
    -       placeholder: "Pull request URL or #42",
    ++        ? `Paste a ${singular} URL, or enter repo#123 for one of the repositories in this thread's folder.`
    ++        : `Paste a ${singular} URL, or enter 123 / #123 for this thread's repository.`,
    +       placeholder: `${capitalizeTerm(singular)} URL or #42`,
            initialValue: "",
            confirmText: "Link",
            onConfirm: (reference) => {
    @@ apps/mobile/src/features/threads/useLinkedPullRequestActions.ts: export function
                hasProject: () => true,
              });
              if (resolved === null) {
    --          Dialog.alert("Couldn't link this pull request", "Use a pull request URL, 123, or #123.");
    +-          Dialog.alert(`Couldn't link this ${singular}`, `Use a ${singular} URL, 123, or #123.`);
     +          Dialog.alert(
    -+            "Couldn't link this pull request",
    ++            `Couldn't link this ${singular}`,
     +            severalRepositories
    -+              ? "Use a pull request URL or repo#123."
    -+              : "Use a pull request URL, 123, or #123.",
    ++              ? `Use a ${singular} URL or repo#123.`
    ++              : `Use a ${singular} URL, 123, or #123.`,
     +          );
                return;
              }
    @@ apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts: describe("pull reque
     +          remoteName: "origin",
     +          remoteUrl: `https://${host}/${repository}.git`,
     +        },
    -+        provider,
    ++        provider: SourceControlProviderKind.make(provider),
     +        displayName: repository,
     +      });
     +      const harness = yield* makeHarness({
    @@ apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts: describe("pull reque
     
      ## apps/server/src/mcp/toolkits/pullRequests/handlers.ts ##
     @@ apps/server/src/mcp/toolkits/pullRequests/handlers.ts: import {
    -   pullRequestHostOf,
    +   CommandId,
        type OrchestrationProjectShell,
        type OrchestrationV2ThreadShell,
     +  type RepositoryIdentity,
    @@ apps/server/src/mcp/toolkits/pullRequests/handlers.ts: interface ResolvedTarget
     +  const wanted = repository.toLowerCase();
     +  const held = checkouts.find((identity) =>
     +    [
    -+      sourceControlRepositorySelector(identity),
    ++      sourceControlDefinitions.repositorySelector(identity),
     +      identity.displayName,
     +      // The path below the host, which is how Azure names a repository in full.
     +      identity.canonicalKey.split("/").slice(1).join("/"),
    @@ apps/server/src/mcp/toolkits/pullRequests/handlers.ts: interface ResolvedTarget
        readonly kind: SourceControlProviderKind | null;
      } {
     -  const identity = project?.repositoryIdentity;
    -   const kind = (identity?.provider as SourceControlProviderKind | undefined) ?? null;
    +   const kind = identity?.provider ?? null;
        if (!identity || kind === null) return { host: null, kind: null };
        return {
     @@ apps/server/src/mcp/toolkits/pullRequests/handlers.ts: const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
    @@ apps/server/src/mcp/toolkits/pullRequests/handlers.ts: const resolveTarget = Eff
        const host = (input.host ?? projectHost.host)?.toLowerCase();
        if (host === undefined) {
          return yield* new PullRequestHostRequiredError({});
    -@@ apps/server/src/mcp/toolkits/pullRequests/handlers.ts: const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
    -       host,
    -       repository,
    -       input.number,
    --      project?.repositoryIdentity?.locator.remoteUrl,
    -+      identity?.locator.remoteUrl,
    -     ) ??
    -     (/(?:^|\/)_git\/[^/]+$/u.test(repository)
    -       ? changeRequestUrlFor(
     @@ apps/server/src/mcp/toolkits/pullRequests/handlers.ts: const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
                host,
                repository,
    -           input.number,
    --          project?.repositoryIdentity?.locator.remoteUrl,
    -+          identity?.locator.remoteUrl,
    -         )
    -       : null) ??
    -     `https://${host}/${repository}/pull/${input.number}`;
    +           number: input.number,
    +-          remoteUrl: project?.repositoryIdentity?.locator.remoteUrl,
    ++          remoteUrl: identity?.locator.remoteUrl,
    +         });
    +   if (url === null) {
    +     return yield* new PullRequestUrlUnavailableError({});
     
      ## apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts ##
     @@ apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts: const layerTest = Layer.mergeAll(RuntimeLayer.layer, RuntimeLayer.layerEventSink
    @@ apps/server/src/orchestration-v2/ThreadPullRequestService.ts: const resolveProje
     +  >,
        options?: { readonly refresh?: boolean },
      ) {
    -   // Identities stay cached for 15 minutes. A finished turn may have added the
    +   // Post-turn discovery refreshes 15-minute identity caches because agents may add remotes.
     
      ## apps/server/src/orchestration-v2/http.ts ##
     @@ apps/server/src/orchestration-v2/http.ts: export const layer = HttpApiBuilder.group(
    -             // Use immediately available enrichment only. Awaiting git-backed
    -             // identity resolution can exceed the client shell-snapshot budget
    -             // (ProcessRunner allows probes up to one minute).
    +           projects,
    +           (project) =>
    +             // Use available enrichment only; git probes can take a minute and exceed shell-snapshot budgets.
     -            projectEnrichment.getAvailable(project.workspaceRoot).pipe(
     -              Effect.map((enrichment) => ({
     -                ...project,
    @@ apps/server/src/project/RepositoryIdentityResolver.ts: import * as Data from "ef
     +import * as Path from "effect/Path";
      
      import * as ProcessRunner from "../processRunner.ts";
    + import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts";
      import { explicitBareRepositoryEnv } from "../vcs/VcsProcess.ts";
    -@@ apps/server/src/project/RepositoryIdentityResolver.ts: const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
    - // and before it saves links) resolve with `refresh: true`.
    + import * as HostProcess from "@t3tools/shared/HostProcess";
    ++import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
    + 
    + const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
    + // Minute-by-minute sweeps need cached hits; clone, publish and PR discovery force refresh.
      const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(15);
      const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1);
     +// Each repository costs three git probes per positive TTL, so a folder of hundreds of
    @@ apps/server/src/project/RepositoryIdentityResolver.ts: export class RepositoryId
            options?: { readonly refresh?: boolean },
          ) => Effect.Effect<RepositoryIdentity | null>;
     +    /**
    -+     * The repositories in a workspace folder: one that is not inside a repository itself but
    -+     * holds checkouts in its immediate child folders, such as an agentctl Sandbox's working
    -+     * directory. Dot-folders and symlinks are skipped, so `.worktrees` is not read as more
    -+     * repositories.
    -+     * Empty for a folder inside a repository, so repository-rooted projects keep one identity.
    ++     * The checkouts in a folder's immediate children, such as an agentctl Sandbox's working
    ++     * directory, skipping dot-folders like `.worktrees` and symlinks. Empty for a folder inside a
    ++     * repository, so repository-rooted projects keep one identity.
     +     */
     +    readonly resolveWorkspace: (
     +      cwd: string,
    @@ apps/server/src/pullRequest/PullRequestService.test.ts: function makeService(inp
        readonly resolveRepositoryIdentity?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"]["resolve"];
     +  readonly resolveWorkspaceRepositories?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"]["resolveWorkspace"];
      }) {
    -   // Built into the test's own scope rather than provided call by call: the marks store owns a
    -   // database, and `Effect.provide` would close it the moment the service was handed back.
    +   // Build in the test scope; per-call Effect.provide would close the marks database on service acquisition.
    +   return Effect.flatMap(
     @@ apps/server/src/pullRequest/PullRequestService.test.ts: function makeService(input: {
              }),
              Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
    @@ apps/server/src/pullRequest/PullRequestService.test.ts: function makeService(inp
     +            : [],
     +        ),
     +      providers: [
    -+        fakeProvider(SourceControlProviderKind.make("github"), {
    ++        fakePullRequestProvider(SourceControlProviderKind.make("github"), {
     +          listChangeRequests: ({ cwd, repository }) =>
     +            Effect.sync(() => {
     +              listed.push([repository, cwd]);
    @@ apps/server/src/pullRequest/PullRequestService.test.ts: function makeService(inp
     +          })),
     +        ),
     +      providers: [
    -+        fakeProvider(SourceControlProviderKind.make("github"), {
    ++        fakePullRequestProvider(SourceControlProviderKind.make("github"), {
     +          listChangeRequests: ({ repository }) =>
     +            Effect.sync(() => {
     +              listed.push(repository);
    @@ apps/server/src/pullRequest/PullRequestService.test.ts: function makeService(inp
     +          })),
     +        ),
     +      providers: [
    -+        fakeProvider(SourceControlProviderKind.make("github"), {
    ++        fakePullRequestProvider(SourceControlProviderKind.make("github"), {
     +          listRepositoryLabels: ({ repository }) =>
     +            Effect.sync(() => {
     +              searched.push(repository);
    @@ apps/server/src/pullRequest/PullRequestService.test.ts: function makeService(inp
     +              : [],
     +          ),
     +        providers: [
    -+          fakeProvider(SourceControlProviderKind.make("github"), {
    ++          fakePullRequestProvider(SourceControlProviderKind.make("github"), {
     +            listChangeRequests: () =>
     +              Effect.succeed({
     +                items: [changeRequest(1, "2026-07-02T00:00:00Z")],
    @@ apps/server/src/pullRequest/PullRequestService.ts: export interface SupportedPro
        readonly unimplemented: ReadonlyMap<
     @@ apps/server/src/pullRequest/PullRequestService.ts: export const make = Effect.gen(function* () {
          ).pipe(Effect.map((resolved) => new Map(resolved)));
    -   };
    +   });
      
     +  /**
     +   * A project with its repository identity resolved. A workspace folder, which is no repository
    @@ apps/server/src/pullRequest/PullRequestService.ts: export const make = Effect.ge
              const seen = new Set<string>();
     +        const excluded = new Set(
     +          (filter.excludeRepositories ?? []).map((key) =>
    -+            canonicalRepositoryKey(key.toLowerCase()),
    ++            sourceControlDefinitions.canonicalRepositoryKey(key.toLowerCase()),
     +          ),
     +        );
              for (const project of projects) {
    @@ apps/server/src/pullRequest/PullRequestService.ts: export const make = Effect.ge
     +          if (
     +            excluded.size > 0 &&
     +            identity.canonicalKey !== undefined &&
    -+            excluded.has(canonicalRepositoryKey(identity.canonicalKey.toLowerCase()))
    ++            excluded.has(
    ++              sourceControlDefinitions.canonicalRepositoryKey(identity.canonicalKey.toLowerCase()),
    ++            )
     +          ) {
     +            continue;
     +          }
    @@ apps/server/src/ws.ts: export const subscribeOrchestrationV2Shell = Effect.fn("w
                    schemaVersion: ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION,
     
      ## apps/web/src/components/ChatView.tsx ##
    -@@ apps/web/src/components/ChatView.tsx: import {
    - import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model";
    - import { projectScriptCwd, resolveProjectScripts } from "@t3tools/shared/projectScripts";
    - import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
    --import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
    +@@
    +-import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
     +import {
     +  projectRepositorySelectors,
    -+  sourceControlRepositorySelector,
    -+} from "@t3tools/shared/sourceControl";
    - import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
    - import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
    - import { useAtomValue } from "@effect/atom-react";
    ++  sourceControlClients,
    ++} from "@t3tools/client-runtime/source-control-clients";
    + import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays";
    + import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer";
    + import { announcedRun, isDetailBehindShell } from "./chat/threadActivityAnnouncement";
     @@ apps/web/src/components/ChatView.tsx: import {
      } from "./ChatView.logic";
      
    @@ apps/web/src/components/ChatView.tsx: import {
      const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = [];
      import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
     @@ apps/web/src/components/ChatView.tsx: export default function ChatView(props: ChatViewProps) {
    -   const activeProjectRepository = sourceControlRepositorySelector(
    +   const activeProjectRepository = sourceControlClients.repositorySelector(
          activeProject?.repositoryIdentity,
        );
     +  const activeProjectRepositories = activeProject ? projectRepositorySelectors(activeProject) : [];
    @@ apps/web/src/components/pullRequest/usePullRequestListView.ts: export function u
     
      ## apps/web/src/components/pullRequest/workspaceRepositoryAssignment.logic.test.ts (new) ##
     @@
    -+import type { EnvironmentId, ProjectId, RepositoryIdentity } from "@t3tools/contracts";
    ++import {
    ++  SourceControlProviderKind,
    ++  type EnvironmentId,
    ++  type ProjectId,
    ++  type RepositoryIdentity,
    ++} from "@t3tools/contracts";
     +import { describe, expect, it } from "vite-plus/test";
     +
     +import {
    @@ apps/web/src/components/pullRequest/workspaceRepositoryAssignment.logic.test.ts
     +      remoteName: "origin",
     +      remoteUrl: `https://${canonicalKey}.git`,
     +    },
    -+    provider: canonicalKey.startsWith("gitlab.") ? "gitlab" : "github",
    ++    provider: SourceControlProviderKind.make(
    ++      canonicalKey.startsWith("gitlab.") ? "gitlab" : "github",
    ++    ),
     +    displayName: path.join("/"),
     +    ...(rootPath === undefined ? {} : { rootPath }),
     +  };
    @@ apps/web/src/components/pullRequest/workspaceRepositoryAssignment.logic.test.ts
     
      ## apps/web/src/components/pullRequest/workspaceRepositoryAssignment.logic.ts (new) ##
     @@
    -+import {
    -+  pullRequestHostOf,
    -+  type EnvironmentId,
    -+  type ProjectId,
    -+  type RepositoryIdentity,
    -+  type SourceControlProviderKind,
    -+} from "@t3tools/contracts";
    -+import {
    -+  canonicalRepositoryKey,
    -+  sourceControlRepositorySelector,
    -+} from "@t3tools/shared/sourceControl";
    ++import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
    ++import type { EnvironmentId, ProjectId, RepositoryIdentity } from "@t3tools/contracts";
     +
     +import type { PickableEnvironment } from "./pullRequestProjectAssignment.logic";
     +
    @@ apps/web/src/components/pullRequest/workspaceRepositoryAssignment.logic.ts (new)
     +
     +/** Empty for an identity stored before it had a key, which then matches nothing. */
     +function keyOf(identity: Pick<RepositoryIdentity, "canonicalKey">): string {
    -+  return identity.canonicalKey ? canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) : "";
    ++  return identity.canonicalKey
    ++    ? sourceControlClients.canonicalRepositoryKey(identity.canonicalKey.toLowerCase())
    ++    : "";
     +}
     +
     +/** The checkouts a project lists from: its own repository, or a folder's checkouts. */
    @@ apps/web/src/components/pullRequest/workspaceRepositoryAssignment.logic.ts (new)
     +function hostOf(identity: RepositoryIdentity): string | null {
     +  return identity.provider === undefined
     +    ? null
    -+    : pullRequestHostOf(identity, identity.provider as SourceControlProviderKind);
    ++    : sourceControlClients.changeRequestHost(identity, identity.provider);
     +}
     +
     +/** The project's checkout of a pull request's repository, matched the way the server does. */
    @@ apps/web/src/components/pullRequest/workspaceRepositoryAssignment.logic.ts (new)
     +  const host = pullRequest.host?.toLowerCase();
     +  return checkoutsOf(project).find(
     +    (identity) =>
    -+      sourceControlRepositorySelector(identity)?.toLowerCase() === repository &&
    ++      sourceControlClients.repositorySelector(identity)?.toLowerCase() === repository &&
     +      (host === undefined || hostOf(identity) === host),
     +  );
     +}
    @@ apps/web/src/composer-logic.test.ts: describe("filterComposerPullRequestMatches"
            limit: 10,
          });
     
    - ## apps/web/src/lib/openPullRequestLink.test.ts ##
    -@@ apps/web/src/lib/openPullRequestLink.test.ts: describe("findProjectForChangeRequest", () => {
    -   const project = (identity: Record<string, unknown>) =>
    -     ({ id: "p1", repositoryIdentity: identity }) as never;
    - 
    -+  it("matches a workspace folder by the repositories checked out in it", () => {
    -+    const repository = (owner: string, name: string) => ({
    -+      canonicalKey: `github.com/${owner}/${name}`,
    -+      provider: "github",
    -+      owner,
    -+      name,
    -+      displayName: `${owner}/${name}`,
    -+    });
    -+    const workspace = {
    -+      id: "sandbox",
    -+      repositoryIdentity: null,
    -+      workspaceRepositories: [repository("acme", "app"), repository("acme", "infra")],
    -+    } as never;
    -+    const reference = { host: "github.com", repository: "acme/infra", number: 7 };
    -+
    -+    expect(findProjectForChangeRequest([workspace], reference)).toBe(workspace);
    -+    expect(findProjectOnChangeRequestHost([workspace], reference)).toBe(workspace);
    -+    // Another repository on the host is read with the folder's credentials, but is not its own.
    -+    const elsewhere = { ...reference, repository: "acme/other" };
    -+    expect(findProjectForChangeRequest([workspace], elsewhere)).toBeUndefined();
    -+    expect(findProjectOnChangeRequestHost([workspace], elsewhere)).toBe(workspace);
    -+    // A plain folder serves no links.
    -+    const plain = { id: "notes", repositoryIdentity: null } as never;
    -+    expect(findProjectOnChangeRequestHost([plain], reference)).toBeUndefined();
    -+  });
    -+
    -   it("matches a nested GitLab group by the whole path below the host", () => {
    -     const projects = [
    -       project({
    -
    - ## apps/web/src/lib/openPullRequestLink.ts ##
    -@@
    --import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts";
    -+import type {
    -+  EnvironmentId,
    -+  PullRequestRef,
    -+  RepositoryIdentity,
    -+  ScopedThreadRef,
    -+} from "@t3tools/contracts";
    - import { useAtomValue } from "@effect/atom-react";
    - import { useNavigate } from "@tanstack/react-router";
    - import { type MouseEvent } from "react";
    -@@ apps/web/src/lib/openPullRequestLink.ts: export {
    -   changeRequestRepositoryUrl,
    - } from "@t3tools/shared/changeRequestUrl";
    - 
    -+/** The repositories a project serves: its own, or those checked out in a workspace folder. */
    -+function projectRepositories(project: EnvironmentProject): ReadonlyArray<RepositoryIdentity> {
    -+  return project.repositoryIdentity != null
    -+    ? [project.repositoryIdentity]
    -+    : (project.workspaceRepositories ?? []);
    -+}
    -+
    - export function findProjectForChangeRequest(
    -   projects: ReadonlyArray<EnvironmentProject>,
    -   link: ChangeRequestLink,
    - ): EnvironmentProject | undefined {
    --  return projects.find((project) => {
    --    const identity = project.repositoryIdentity;
    --    return (
    --      identity?.provider !== undefined &&
    --      sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link)
    --    );
    --  });
    -+  return projects.find((project) =>
    -+    projectRepositories(project).some(
    -+      (identity) =>
    -+        identity.provider !== undefined &&
    -+        sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link),
    -+    ),
    -+  );
    - }
    - 
    - export function resolvePullRequestPreviewTarget({
    -@@ apps/web/src/lib/openPullRequestLink.ts: export function findProjectOnChangeRequestHost(
    - ): EnvironmentProject | undefined {
    -   const own = findProjectForChangeRequest(projects, link);
    -   if (own !== undefined) return own;
    --  return projects.find((project) => {
    --    const identity = project.repositoryIdentity;
    --    return (
    --      identity?.provider !== undefined &&
    --      sourceControlClients.get(identity.provider).canReadChangeRequestOnHost(identity, link)
    --    );
    --  });
    -+  return projects.find((project) =>
    -+    projectRepositories(project).some(
    -+      (identity) =>
    -+        identity.provider !== undefined &&
    -+        sourceControlClients.get(identity.provider).canReadChangeRequestOnHost(identity, link),
    -+    ),
    -+  );
    - }
    - 
    - export function shouldOpenPullRequestExternally(
    -
      ## docs/user/agentctl-sandboxes.md ##
     @@ docs/user/agentctl-sandboxes.md: gives it.
      - Stopping keeps the Sandbox's disk. Its threads stay listed and reconnect when it starts again.
    @@ docs/user/source-control.md: and their marks follow the same rules as on web and
      
      A thread can hold several pull requests, including reviews from another repository on the same host.
      Use **Link pull request** in the command palette or **Linked pull requests** panel, or right-click a
    --pull request link in the conversation. Creating a pull request from Git actions links it automatically.
    +-pull request link in the conversation. Creating a pull request from Git actions links it automatically,
    +-and so does an agent creating one with `gh pr create` or `glab mr create`. Agents can link other
    +-pull requests with the `link_pull_request` tool.
     +pull request link in the conversation. A number links the project's own pull request; in a project
     +folder that holds several repositories, write `repo#123`. Creating a pull request from Git actions
    -+links it automatically.
    - Agents can link their pull requests with the `link_pull_request` tool.
    ++links it automatically, and so does an agent creating one with `gh pr create` or `glab mr create`.
    ++Agents can link other pull requests with the `link_pull_request` tool.
      
      Use **Link this PR** in a branch-detected badge's tooltip to keep it with the thread. From a review
    + on the Pull Requests page, **Link to thread** lets you search for an active thread. The review header
    +
    + ## packages/client-runtime/src/changeRequestLinks.test.ts ##
    +@@ packages/client-runtime/src/changeRequestLinks.test.ts: describe("findProjectForChangeRequest", () => {
    +   const project = (identity: Record<string, unknown>) =>
    +     ({ id: "p1", repositoryIdentity: identity }) as never;
    + 
    ++  it("matches a workspace folder by the repositories checked out in it", () => {
    ++    const repository = (owner: string, name: string) => ({
    ++      canonicalKey: `github.com/${owner}/${name}`,
    ++      provider: "github",
    ++      owner,
    ++      name,
    ++      displayName: `${owner}/${name}`,
    ++    });
    ++    const workspace = {
    ++      id: "sandbox",
    ++      repositoryIdentity: null,
    ++      workspaceRepositories: [repository("acme", "app"), repository("acme", "infra")],
    ++    } as never;
    ++    const reference = { host: "github.com", repository: "acme/infra", number: 7 };
    ++
    ++    expect(findProjectForChangeRequest([workspace], reference)).toBe(workspace);
    ++    expect(findProjectOnChangeRequestHost([workspace], reference)).toBe(workspace);
    ++    // Another repository on the host is read with the folder's credentials, but is not its own.
    ++    const elsewhere = { ...reference, repository: "acme/other" };
    ++    expect(findProjectForChangeRequest([workspace], elsewhere)).toBeUndefined();
    ++    expect(findProjectOnChangeRequestHost([workspace], elsewhere)).toBe(workspace);
    ++    // A plain folder serves no links.
    ++    const plain = { id: "notes", repositoryIdentity: null } as never;
    ++    expect(findProjectOnChangeRequestHost([plain], reference)).toBeUndefined();
    ++  });
    ++
    +   it("matches a nested GitLab group by the whole path below the host", () => {
    +     const projects = [
    +       project({
    +
    + ## packages/client-runtime/src/changeRequestLinks.ts ##
    +@@ packages/client-runtime/src/changeRequestLinks.ts: interface ChangeRequestProject {
    +   readonly id: ProjectId;
    +   readonly environmentId: EnvironmentId;
    +   readonly repositoryIdentity?: RepositoryIdentity | null | undefined;
    ++  readonly workspaceRepositories?: ReadonlyArray<RepositoryIdentity> | undefined;
    ++}
    ++
    ++/** The repositories a project serves: its own, or those checked out in a workspace folder. */
    ++function projectRepositories(project: ChangeRequestProject): ReadonlyArray<RepositoryIdentity> {
    ++  return project.repositoryIdentity != null
    ++    ? [project.repositoryIdentity]
    ++    : (project.workspaceRepositories ?? []);
    + }
    + 
    + /** The project whose own repository holds the change request, not just one on its host. */
    +@@ packages/client-runtime/src/changeRequestLinks.ts: export function findProjectForChangeRequest<Project extends ChangeRequestProject
    +   projects: ReadonlyArray<Project>,
    +   link: ChangeRequestLink,
    + ): Project | undefined {
    +-  return projects.find((project) => {
    +-    const identity = project.repositoryIdentity;
    +-    return (
    +-      identity?.provider !== undefined &&
    +-      sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link)
    +-    );
    +-  });
    ++  return projects.find((project) =>
    ++    projectRepositories(project).some(
    ++      (identity) =>
    ++        identity.provider !== undefined &&
    ++        sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link),
    ++    ),
    ++  );
    + }
    + 
    + /** Prefers the project checked out from the link's repository, else lends any project on its host. */
    +@@ packages/client-runtime/src/changeRequestLinks.ts: export function findProjectOnChangeRequestHost<Project extends ChangeRequestProj
    + ): Project | undefined {
    +   return (
    +     findProjectForChangeRequest(projects, link) ??
    +-    projects.find((project) => {
    +-      const identity = project.repositoryIdentity;
    +-      return (
    +-        identity?.provider !== undefined &&
    +-        sourceControlClients.get(identity.provider).canReadChangeRequestOnHost(identity, link)
    +-      );
    +-    })
    ++    projects.find((project) =>
    ++      projectRepositories(project).some(
    ++        (identity) =>
    ++          identity.provider !== undefined &&
    ++          sourceControlClients.get(identity.provider).canReadChangeRequestOnHost(identity, link),
    ++      ),
    ++    )
    +   );
    + }
    + 
     
      ## packages/client-runtime/src/pullRequestLinkInput.test.ts ##
    +@@ packages/client-runtime/src/pullRequestLinkInput.test.ts: describe("resolveLinkPullRequestInput", () => {
    +       ).not.toBeNull();
    +       expect(parsePullRequestReference(url)).toBe(url);
    +       expect(
    +-        resolveLinkPullRequestInput({ reference: url, project: null, hasProject: () => true }),
    ++        resolveLinkPullRequestInput({ reference: url, repositories: [], hasProject: () => true }),
    +       ).toEqual({
    +         link: {
    +           host,
     @@ packages/client-runtime/src/pullRequestLinkInput.test.ts: describe("resolveLinkPullRequestInput", () => {
          expect(
            resolveLinkPullRequestInput({
    @@ packages/client-runtime/src/pullRequestLinkInput.test.ts: describe("resolveLinkP
     -        project: {
     -          host: "ssh.dev.azure.com",
     -          repository: "v3/org/project/web",
    +-          terminology: sourceControlClients.get("azure-devops").changeRequest,
     -          webUrl: (number) =>
     -            changeRequestWebUrl("azure-devops", "ssh.dev.azure.com", "v3/org/project/web", number),
     -        },
    @@ packages/client-runtime/src/pullRequestLinkInput.test.ts: describe("resolveLinkP
     +          {
     +            host: "ssh.dev.azure.com",
     +            repository: "v3/org/project/web",
    ++            terminology: sourceControlClients.get("azure-devops").changeRequest,
     +            webUrl: (number) =>
     +              changeRequestWebUrl(
     +                "azure-devops",
    @@ packages/client-runtime/src/pullRequestLinkInput.test.ts: describe("resolveLinkP
          ).toEqual({
            link: {
              host: "github.com",
    +@@ packages/client-runtime/src/pullRequestLinkInput.test.ts: describe("resolveLinkPullRequestInput", () => {
    +     expect(
    +       resolveLinkPullRequestInput({
    +         reference: "#42",
    +-        project: {
    +-          ...project,
    +-          terminology: sourceControlClients.get("gitlab").changeRequest,
    +-          webUrl: () => null,
    +-        },
    ++        repositories: [
    ++          {
    ++            ...project,
    ++            terminology: sourceControlClients.get("gitlab").changeRequest,
    ++            webUrl: () => null,
    ++          },
    ++        ],
    +         hasProject: () => true,
    +       }),
    +     ).toEqual({
     @@ packages/client-runtime/src/pullRequestLinkInput.test.ts: describe("resolveLinkPullRequestInput", () => {
          expect(
            resolveLinkPullRequestInput({
    @@ packages/client-runtime/src/pullRequestLinkInput.test.ts: describe("resolveLinkP
     +    const infra = {
     +      host: "github.com",
     +      repository: "acme/infra",
    ++      terminology: sourceControlClients.get("github").changeRequest,
     +      webUrl: (number: number) => changeRequestWebUrl("github", "github.com", "acme/infra", number),
     +    };
     +    const repositories = [project, infra];
    @@ packages/client-runtime/src/pullRequestLinkInput.test.ts: describe("resolveLinkP
      describe("parsePullRequestReference", () => {
     
      ## packages/client-runtime/src/pullRequestLinkInput.ts ##
    +@@
    + import type { RepositoryIdentity } from "@t3tools/contracts";
    + import { canonicalizeGitCafePullRequestUrl } from "@t3tools/source-control-definitions/registry";
    + 
    +-import { sourceControlClients, type ChangeRequestTerminology } from "./sourceControlClients.ts";
    ++import { sourceControlClients } from "./sourceControlClients.ts";
    + 
    + const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/;
    + 
     @@ packages/client-runtime/src/pullRequestLinkInput.ts: export function parsePullRequestReference(input: string): string | null {
        return null;
      }
    @@ packages/client-runtime/src/pullRequestLinkInput.ts: interface ResolvedLink {
     +  number: number,
     +): { link: ResolvedLink } | { error: string } {
     +  const webUrl = repository.webUrl(number);
    -+  const webReference = webUrl === null ? null : parseChangeRequestUrl(webUrl);
    -+  if (webUrl === null || webReference === null) {
    -+    return { error: "Paste a full URL; this project's host has no known pull request URL." };
    ++  const webReference =
    ++    webUrl === null ? undefined : sourceControlClients.parseChangeRequestUrl(webUrl)?.link;
    ++  if (webUrl === null || webReference === undefined) {
    ++    return {
    ++      error: `Paste a full URL; this project's host has no known ${repository.terminology.singular} URL.`,
    ++    };
     +  }
    -+  return { link: { ...webReference, url: webUrl } };
    ++  return {
    ++    link: {
    ++      host: webReference.host,
    ++      repository: webReference.repository,
    ++      number: webReference.number,
    ++      url: webUrl,
    ++    },
    ++  };
     +}
     +
     +/**
    @@ packages/client-runtime/src/pullRequestLinkInput.ts: interface ResolvedLink {
     -  readonly project: {
     -    readonly host: string;
     -    readonly repository: string;
    +-    readonly terminology: ChangeRequestTerminology;
     -    readonly webUrl: (number: number) => string | null;
     -  } | null;
     +  readonly repositories: ReadonlyArray<LinkRepository>;
        readonly hasProject: (reference: ResolvedLink) => boolean;
      }): { link: ResolvedLink } | { error: string } | null {
    +-  const parsed =
    +-    sourceControlClients.parseChangeRequestUrl(input.reference.trim()) !== null
    +-      ? input.reference.trim()
    +-      : parsePullRequestReference(input.reference);
     +  const trimmed = input.reference.trim();
    -+  const named =
    -+    parseChangeRequestUrl(trimmed) === null
    -+      ? /^([\w.-]+(?:\/[\w.-]+)*)#(\d+)$/u.exec(trimmed)
    -+      : null;
    ++  const isUrl = sourceControlClients.parseChangeRequestUrl(trimmed) !== null;
    ++  const named = isUrl ? null : /^([\w.-]+(?:\/[\w.-]+)*)#(\d+)$/u.exec(trimmed);
     +  if (named?.[1] && named[2]) {
     +    const name = named[1].toLowerCase();
     +    const matches = input.repositories.filter((candidate) => {
    @@ packages/client-runtime/src/pullRequestLinkInput.ts: interface ResolvedLink {
     +          : `${named[1]} names more than one repository here; write owner/repo#${number}.`,
     +    };
     +  }
    -   const parsed =
    --    parseChangeRequestUrl(input.reference.trim()) !== null
    --      ? input.reference.trim()
    --      : parsePullRequestReference(input.reference);
    -+    parseChangeRequestUrl(trimmed) !== null ? trimmed : parsePullRequestReference(input.reference);
    ++  const parsed = isUrl ? trimmed : parsePullRequestReference(input.reference);
        if (parsed === null) return null;
    -   const url = parseChangeRequestUrl(parsed);
    -   if (url !== null) {
    +   const url = sourceControlClients.parseChangeRequestUrl(parsed)?.link;
    +   if (url !== undefined) {
     @@ packages/client-runtime/src/pullRequestLinkInput.ts: export function resolveLinkPullRequestInput(input: {
        }
        const number = Number(parsed);
    @@ packages/client-runtime/src/pullRequestLinkInput.ts: export function resolveLink
          };
        }
     -  const webUrl = input.project.webUrl(number);
    --  const webReference = webUrl === null ? null : parseChangeRequestUrl(webUrl);
    --  if (webUrl === null || webReference === null) {
    --    return { error: "Paste a full URL; this project's host has no known pull request URL." };
    +-  const webReference =
    +-    webUrl === null ? undefined : sourceControlClients.parseChangeRequestUrl(webUrl)?.link;
    +-  if (webUrl === null || webReference === undefined) {
    +-    return {
    +-      error: `Paste a full URL; this project's host has no known ${input.project.terminology.singular} URL.`,
    +-    };
     +  if (others.length > 0) {
     +    return { error: "This folder holds several repositories; enter repo#123, or paste the URL." };
        }
     -  return {
    --    link: { ...webReference, url: webUrl },
    +-    link: {
    +-      host: webReference.host,
    +-      repository: webReference.repository,
    +-      number: webReference.number,
    +-      url: webUrl,
    +-    },
     -  };
     +  return linkInRepository(only, number);
      }
     
    + ## packages/client-runtime/src/sourceControlClients.test.ts ##
    +@@
    + import { ProjectId, SourceControlProviderKind } from "@t3tools/contracts";
    + import { describe, expect, it } from "vite-plus/test";
    + 
    +-import { sourceControlClients } from "./sourceControlClients.ts";
    ++import { projectRepositorySelectors, sourceControlClients } from "./sourceControlClients.ts";
    + import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts";
    + 
    + const parse = (url: string) => {
    +@@ packages/client-runtime/src/sourceControlClients.test.ts: describe("sourceControlClients.findByPublicDomain", () => {
    +     expect(sourceControlClients.findByPublicHost("gist.github.com")).toBeUndefined();
    +   });
    + });
    ++
    ++describe("projectRepositorySelectors", () => {
    ++  const identity = (displayName: string) => ({
    ++    canonicalKey: `github.com/${displayName.toLowerCase()}`,
    ++    locator: {
    ++      source: "git-remote" as const,
    ++      remoteName: "origin",
    ++      remoteUrl: `https://github.com/${displayName}.git`,
    ++    },
    ++    provider: SourceControlProviderKind.make("github"),
    ++    displayName,
    ++  });
    ++
    ++  it("names a workspace folder's repositories once each", () => {
    ++    expect(
    ++      projectRepositorySelectors({
    ++        repositoryIdentity: null,
    ++        workspaceRepositories: [identity("acme/app"), identity("Acme/App"), identity("acme/infra")],
    ++      }),
    ++    ).toEqual(["acme/app", "acme/infra"]);
    ++    expect(projectRepositorySelectors({ repositoryIdentity: identity("acme/app") })).toEqual([
    ++      "acme/app",
    ++    ]);
    ++    expect(projectRepositorySelectors({ repositoryIdentity: null })).toEqual([]);
    ++  });
    ++});
    +
    + ## packages/client-runtime/src/sourceControlClients.ts ##
    +@@ packages/client-runtime/src/sourceControlClients.ts: import {
    +   HostCredentialSettings,
    +   readSourceControlHostSettings,
    + } from "@t3tools/source-control-core/shared/definition";
    +-import type { ServerSettings } from "@t3tools/contracts";
    ++import type { RepositoryIdentity, ServerSettings } from "@t3tools/contracts";
    + 
    + export {
    +   isMonochromeSourceControlIcon,
    +@@ packages/client-runtime/src/sourceControlClients.ts: export function readHostCredentialSettings(
    +   return readSourceControlHostSettings(HostCredentialSettings, settings.sourceControlHosts[kind]);
    + }
    + 
    ++/**
    ++ * The repositories a project's pull requests come from, as `repositorySelector` names them: its
    ++ * own, or for a folder that is no repository, the checkouts it holds. Two checkouts of one
    ++ * repository name it once.
    ++ */
    ++export function projectRepositorySelectors(project: {
    ++  readonly repositoryIdentity?: RepositoryIdentity | null | undefined;
    ++  readonly workspaceRepositories?: ReadonlyArray<RepositoryIdentity> | undefined;
    ++}): ReadonlyArray<string> {
    ++  const identities =
    ++    project.repositoryIdentity != null
    ++      ? [project.repositoryIdentity]
    ++      : (project.workspaceRepositories ?? []);
    ++  const selectors = new Map<string, string>();
    ++  for (const identity of identities) {
    ++    const selector = sourceControlClients.repositorySelector(identity);
    ++    if (selector !== null && !selectors.has(selector.toLowerCase())) {
    ++      selectors.set(selector.toLowerCase(), selector);
    ++    }
    ++  }
    ++  return [...selectors.values()];
    ++}
    ++
    + /** A host's change request term where it opens a sentence: "Merge request merged". */
    + export function capitalizeTerm(term: string): string {
    +   return term.charAt(0).toUpperCase() + term.slice(1);
    +
      ## packages/client-runtime/src/state/shellReducer.test.ts ##
     @@ packages/client-runtime/src/state/shellReducer.test.ts: describe("applyShellStreamEvent", () => {
          expect(withoutIdentity.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
    @@ packages/client-runtime/src/state/shellReducer.ts: function upsertById<T extends
        return next;
      }
     @@ packages/client-runtime/src/state/shellReducer.ts: export function mergeShellSnapshotProjects(
    -         if (resolvedRootSet?.has(project.workspaceRoot) === true) {
    -           // Refreshes re-resolve every root; an unchanged identity keeps the project's identity
    -           // so every subscriber (each pull request row, the sidebar) skips re-rendering.
    --          if (sameRepositoryIdentity(project.repositoryIdentity, candidate.repositoryIdentity)) {
    -+          if (
    -+            sameRepositoryIdentity(project.repositoryIdentity, candidate.repositoryIdentity) &&
    -+            sameWorkspaceRepositories(
    -+              project.workspaceRepositories,
    -+              candidate.workspaceRepositories,
    -+            )
    -+          ) {
    -             return project;
    -           }
    --          return { ...project, repositoryIdentity: candidate.repositoryIdentity };
    -+          return withRepositoriesOf(project, candidate);
    +       if (resolvedRootSet?.has(project.workspaceRoot) === true) {
    +         // Refreshes re-resolve every root; an unchanged identity keeps the project's identity
    +         // so every subscriber (each pull request row, the sidebar) skips re-rendering.
    +-        if (sameRepositoryIdentity(project.repositoryIdentity, candidate.repositoryIdentity)) {
    ++        if (
    ++          sameRepositoryIdentity(project.repositoryIdentity, candidate.repositoryIdentity) &&
    ++          sameWorkspaceRepositories(project.workspaceRepositories, candidate.workspaceRepositories)
    ++        ) {
    +           return project;
              }
    --        if (project.repositoryIdentity == null && candidate.repositoryIdentity != null) {
    --          return { ...project, repositoryIdentity: candidate.repositoryIdentity };
    -+        if (!hasRepositories(project) && hasRepositories(candidate)) {
    -+          return withRepositoriesOf(project, candidate);
    -         }
    -         return project;
    -       }),
    +-        return { ...project, repositoryIdentity: candidate.repositoryIdentity };
    ++        return withRepositoriesOf(project, candidate);
    +       }
    +-      if (project.repositoryIdentity == null && candidate.repositoryIdentity != null) {
    +-        return { ...project, repositoryIdentity: candidate.repositoryIdentity };
    ++      if (!hasRepositories(project) && hasRepositories(candidate)) {
    ++        return withRepositoriesOf(project, candidate);
    +       }
    +       return project;
    +     });
     
      ## packages/contracts/src/orchestrationProject.ts ##
     @@ packages/contracts/src/orchestrationProject.ts: export const OrchestrationProjectShell = Schema.Struct({
    @@ packages/shared/src/composerPullRequestMatches.test.ts: describe("linked pull re
      });
     
      ## packages/shared/src/composerPullRequestMatches.ts ##
    -@@
    - import type { ThreadPullRequestLink } from "@t3tools/contracts";
    - 
    --import { visibleThreadPullRequests } from "./threadPullRequests.ts";
    -+import { legacyThreadPullRequestKey, visibleThreadPullRequests } from "./threadPullRequests.ts";
    +@@ packages/shared/src/composerPullRequestMatches.ts: import type { ThreadPullRequestLink } from "@t3tools/contracts";
    + import { visibleThreadPullRequests } from "./threadPullRequests.ts";
      
      export interface ComposerPullRequestMatch {
     +  /** Keeps the same repository on two hosts apart; entries without one share a key. */
    @@ packages/shared/src/composerPullRequestMatches.ts: export function filterCompose
     -  const isLinked = (entry: ComposerPullRequestMatch) => linkedKeys.has(keyOf(entry));
     +  const isLinked = (entry: ComposerPullRequestMatch) =>
     +    linkedKeys.has(composerPullRequestKey(entry));
    -   // `.sort()` on a copy, not `.toSorted()`: this runs on Hermes, which has no ES2023 array
    -   // methods, and reaching for one here crashed the composer as the suggestions loaded.
    +   // Hermes has no ES2023 array methods, so sort a fresh array.
        return [...uniqueEntries.values()]
    +     .sort(
     @@ packages/shared/src/composerPullRequestMatches.ts: export function filterComposerPullRequestMatches<
          )
          .slice(0, input.limit);
    @@ packages/shared/src/composerPullRequestMatches.ts: export function filterCompose
     +export function composerPullRequestKey(
     +  entry: Pick<ComposerPullRequestMatch, "host" | "url" | "repository" | "number">,
     +): string {
    -+  const key =
    -+    entry.host === undefined && entry.url !== undefined
    -+      ? legacyThreadPullRequestKey({ ...entry, url: entry.url })
    -+      : entry;
    -+  return `${key.host?.toLowerCase() ?? ""}/${key.repository.trim().toLowerCase()}#${key.number}`;
    ++  const host = entry.host ?? (entry.url === undefined ? undefined : urlHost(entry.url));
    ++  return `${host?.toLowerCase() ?? ""}/${entry.repository.trim().toLowerCase()}#${entry.number}`;
     +}
    -
    - ## packages/shared/src/sourceControl.test.ts ##
    -@@ packages/shared/src/sourceControl.test.ts: import { SourceControlProviderKind } from "@t3tools/contracts";
    - import { describe, expect, it } from "vite-plus/test";
    - 
    - import {
    -+  projectRepositorySelectors,
    -   sourceControlRepositorySelector,
    -   detectSourceControlProviderFromRemoteUrl,
    -   getChangeRequestTerminologyForKind,
    -@@ packages/shared/src/sourceControl.test.ts: it("keeps a GitLab identity's whole path, because a nested group is part of the
    -   });
    -   expect(selector).toBe("group/subgroup/service");
    - });
    -+
    -+describe("projectRepositorySelectors", () => {
    -+  const identity = (displayName: string) => ({
    -+    canonicalKey: `github.com/${displayName.toLowerCase()}`,
    -+    locator: {
    -+      source: "git-remote" as const,
    -+      remoteName: "origin",
    -+      remoteUrl: `https://github.com/${displayName}.git`,
    -+    },
    -+    provider: "github",
    -+    displayName,
    -+  });
     +
    -+  it("names a workspace folder's repositories once each", () => {
    -+    expect(
    -+      projectRepositorySelectors({
    -+        repositoryIdentity: null,
    -+        workspaceRepositories: [identity("acme/app"), identity("Acme/App"), identity("acme/infra")],
    -+      }),
    -+    ).toEqual(["acme/app", "acme/infra"]);
    -+    expect(projectRepositorySelectors({ repositoryIdentity: identity("acme/app") })).toEqual([
    -+      "acme/app",
    -+    ]);
    -+    expect(projectRepositorySelectors({ repositoryIdentity: null })).toEqual([]);
    -+  });
    -+});
    -
    - ## packages/shared/src/sourceControl.ts ##
    -@@ packages/shared/src/sourceControl.ts: export function sourceControlRepositorySelector(
    -   return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null;
    - }
    - 
    -+/**
    -+ * The repositories a project's pull requests come from, as `sourceControlRepositorySelector`
    -+ * names them: its own, or for a folder that is no repository, the checkouts it holds. Two
    -+ * checkouts of one repository name it once.
    -+ */
    -+export function projectRepositorySelectors(project: {
    -+  readonly repositoryIdentity?: RepositoryIdentity | null | undefined;
    -+  readonly workspaceRepositories?: ReadonlyArray<RepositoryIdentity> | undefined;
    -+}): ReadonlyArray<string> {
    -+  const identities =
    -+    project.repositoryIdentity != null
    -+      ? [project.repositoryIdentity]
    -+      : (project.workspaceRepositories ?? []);
    -+  const selectors = new Map<string, string>();
    -+  for (const identity of identities) {
    -+    const selector = sourceControlRepositorySelector(identity);
    -+    if (selector !== null && !selectors.has(selector.toLowerCase())) {
    -+      selectors.set(selector.toLowerCase(), selector);
    -+    }
    ++function urlHost(url: string): string | undefined {
    ++  try {
    ++    return new URL(url).host;
    ++  } catch {
    ++    return undefined;
     +  }
    -+  return [...selectors.values()];
     +}
    -+
    - export function canonicalRepositoryKey(key: string): string {
    -   return key
    -     .replace(
     
      ## packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts ##
     @@ packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts: describe("selectGitHubBaseRepository", () => {

@martinothamar

Copy link
Copy Markdown
Owner Author

Range-diff, commit 5: 5: ee02861281 ! 5: 28648f0a9c fix(desktop): brand preview builds Beta at runtime, as they are packaged (#33)

    @@ apps/desktop/src/updates/updateChannels.ts
      import type { DesktopUpdateChannel } from "@t3tools/contracts";
      
      const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/;
    --// They share nightly's branding but are packaged without an update feed (see
    --// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the channel
    --// a preview install reports is cosmetic: it never checks for updates and no
    --// updater feed ever lists a preview release.
    +-// Preview builds share nightly branding but ship without an update feed.
     -const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/;
      
     +// Preview builds are packaged with the stable product name and icons (see
6:  d4dbb5e29b < -:  ---------- docs(fork): say that GitHub marks the sync pull request merged when main moves (#40)

@martinothamar
martinothamar merged commit 28648f0 into main Oct 25, 2026
32 checks passed
@martinothamar
martinothamar deleted the sync/2026-10-25 branch October 25, 2026 19:19
@martinothamar

Copy link
Copy Markdown
Owner Author

The rewrite is applied. main now points at 28648f0, and the old tip d4dbb5e is archived as archive/main-2026-10-25.

The live-tested build from run 40222450900 is published as v0.1.3-preview.20261025.30, by run 40225531466.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant