Repository navigation
Sync with upstream v0.1.3-nightly.20261025.3669 (review only, do not merge) - #41
Conversation
|
Range-diff, commit 1: @@ 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"; |
|
Range-diff, commit 2: @@ 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) => |
|
Range-diff, commit 3: @@ 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, |
|
Range-diff, commit 4: @@ 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", () => { |
|
Range-diff, commit 5: @@ 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 |
|
The rewrite is applied. The live-tested build from run 40222450900 is published as v0.1.3-preview.20261025.30, by run 40225531466. |
Review only — do not merge. This shows the rewrite of
mainfor the upstream sync. Once it is approved, the old tip is archived asarchive/main-2026-10-25andmainis force-pushed to this branch. GitHub then marks this PR merged.Base
Upstream nightly
v0.1.3-nightly.20261025.3669(5f64c5e), the latest, moving fromv0.1.3-nightly.20261024.3617(27e0c68). That's 109 upstream commits. Among them isupstream#24867, which fixes desktop release builds after the vite-plus 1.4 upgrade; the Fork release workflow needs it.upstream/mainis 6 small commits past this nightly, which isn't enough reason to sync to a non-nightly ref.Mapping
main#40 (the sync PR is marked merged when
mainmoves) is folded into the setup commit, which already ownsagents/dev/instructions.md.Upstream CI
Upstream changed
ci.yml: each job now getsid-token: writeand passesT3_TASK_CACHE_READ_TOKENtosetup-ci-deps.fork-ci.ymlwas regenerated from the newci.ymlwith 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, sosetup-ci-depsskips the remote cache, which itsinputs.task-cache-read-token != ''guard handles.release.ymlchanged only in its test jobs' setup step.build_bundleandrelease-desktop.ymlare unchanged, sofork-release.ymlstill fits them.Conflicts
Setup commit. No conflicts apart from
fork-ci.yml, described above.#5, PR watch marker.
packages/provider-core/src/server/runtimeInstructions.tsandapps/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.pullRequestWatch.ts: keptbyAgentand draft hold-back, with upstream's shorter comments.gitHubPullRequestJson.ts: kept the fork'snewest()helper, which adds reviewsubmittedAtto the fingerprint.pullRequestWatch.forgejoComments.test.ts: upstream deleted it and moved its assertions intopullRequestWatch.test.ts(upstream#24000). The fork's changes went into the moved test:owninremarkschanges, 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)./sandboxesstays a sidebar utility page, beside Projects and Pull Requests.routeTree.gen.tswas regenerated.Workspace-folder pull requests. This is the large one. Upstream's forge refactor (
upstream#23992toupstream#24288, plusupstream#23999,upstream#24738andupstream#22634) moved repository helpers out of@t3tools/shared/sourceControlinto per-forge registries (sourceControlDefinitionson the server,sourceControlClientson clients). The fork code now uses those:projectRepositorySelectorsmoved frompackages/shared/src/sourceControl.tstopackages/client-runtime/src/sourceControlClients.ts, and its test moved with it. Web and mobile import it from there.sourceControlRepositorySelector,canonicalRepositoryKeyandpullRequestHostOfare now the registry'srepositorySelector,canonicalRepositoryKeyandchangeRequestHost. That covers the web assignment helper, the mobile environment fallback, MCPhandlers.tsandPullRequestService.ts.findProjectForChangeRequest/findProjectOnChangeRequestHostmoved from the web app toclient-runtime/changeRequestLinks.ts, so mobile now uses them too (upstream#24738). The fork's matching against a folder'sworkspaceRepositoriesis ported there, and its test went tochangeRequestLinks.test.ts.pullRequestLinkInput.ts: the fork's list of repositories andrepo#123handling 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.repo#123prompt for folders with several repositories.composerPullRequestKey:legacyThreadPullRequestKeyhas leftpackages/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 Azurevisualstudio.comURLs. Such an entry no longer merges with its linked snapshot, so it can show twice in#suggestions.shellReducer.ts: the fork'sworkspaceRepositoriescomparison is combined with upstream's change that returns the previous snapshot when nothing changed.orchestration-v2/http.ts: keptProjectEnrichmentService.withEnrichment, with upstream's comment.handlers.tsand its test: kept upstream's GitCafe test and the fork's workspace-folder test.resolveTargetuses the checkout's identity with upstream's forge-recognition logic.docs/user/source-control.mdkeeps both upstream's line aboutgh pr create/glab mr createand the fork'srepo#123sentence. Test helpers follow upstream's renames (fakePullRequestProvider, brandedSourceControlProviderKind).#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 ininstall-desktop.tsand one inseed-sandbox-server.ts(setup);catalog.ts,sandboxConnections.ts,useSandboxes.tsandsandboxSettingsSeed.ts(Sandboxes); andRepositoryIdentityResolver.ts(workspace folders).Checks
vp run check:changed v0.1.3-nightly.20261025.3669passes 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:
apps/server(PR service, identity resolver, enrichment, MCP handlers, project service, PR watch, runtime layer)packages/client-runtimepackages/sharedpackages/source-control-githubapps/webpull-request and composerupdateChannelsSandbox
GITHUB_TOKEN: one GitHub credentials test fails only when the sandbox'sGITHUB_TOKENis in the environment. I ran with it unset, as CI does.Fork-only files (
scripts/fork,.github/workflows/fork-*.yml,Makefile,README.md,agents) matchmain, apart fromfork-ci.ymland the comment trims above.Upstream features against the fork's:
upstream#24813links 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-25is 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.30from 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-solagent (xhighreasoning, priority tier) live-tested that build against a nested Sandbox,qa-sync-20261025, created from this branch'sagents/devnestedvariant. Everything passed:t3 v0.1.3-preview.20261025.30hostnamefrom Codex (GPT-6-Luna, Low) and Claude (Haiku 5.5, Low)qa-sync-20261025hostnamestill matched. Delete removed the Sandbox, its project and threads; agentctl reports no resources41links (then unlinked); a pasted PR URL opens in the app<!-- t3code:agent -->rule reaches agentshostnamehostnameOddities during startup. All three recovered on their own, and none is a confirmed regression:
codex --version did not finish within 5swhile the nested VM was under a cold-start load of 5.7. The retry succeeded.Not covered:
repo#123disambiguation: the Sandbox held a single checkout.Rewritten by Claude Opus 5.5 in Claude Code.