Project discovery and t3.json
A T3 project is an environment-local durable record for one normalized workspace root; checked-in t3.json and Git-remote identity enrich its behavior and presentation without replacing that record.
What this chapter resolves
- Separate a filesystem workspace root, a durable project record, a Git identity, and a cross-environment presentation group.
- Trace t3.json decoding, its constrained schema, and the precedence of explicit, project, checked-in, and global workspace choices.
- Follow project creation through normalization, command invariants, durable events, and projected read models.
- Inspect setup-script environment labels without mistaking them for a shell sandbox or project identity.
“Project” is deliberately not a synonym for “Git repository.” At this pinned
revision, it starts as an environment-local durable record with a normalized
directory as its workspaceRoot. Git can later provide an identity for that
directory; clients can then choose to group equivalent identities across
environments. Neither upgrade makes paths, directories, and project records the
same thing.
Four layers answer four different questions
| Layer | Question it answers | What it is not |
|---|---|---|
| Workspace root | Is this supplied path a directory T3 may use? | A proof that it is Git-backed or unique across machines |
| Project record | Which durable project id, title, defaults, scripts, and lifecycle state belong here? | A recursive filesystem scan or a repository identity |
| Repository identity | Which normalized fetch remote describes this Git repository? | A durable project event field that every directory must have |
| Logical project group | Which physical project entries should a client present together? | A replacement for the selected environment/project target |
The root service trims and resolves a supplied path (including a leading home shortcut), verifies it is a directory, and can create a missing directory only when the caller explicitly requests that. Relative files are separately resolved under a validated root; absolute paths and traversal outside it are rejected.
const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn(
"WorkspacePaths.normalizeWorkspaceRoot",
)(function* (workspaceRoot, options) {
const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path));
let workspaceStat = yield* statWorkspaceRoot(
workspaceRoot,
normalizedWorkspaceRoot,
"validate-existing",
);
if (!workspaceStat && options?.createIfMissing) {
yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe(
Effect.mapError(
(cause) =>
new WorkspaceRootCreateFailedError({
workspaceRoot,
normalizedWorkspaceRoot,
cause,
}),
),
);
workspaceStat = yield* statWorkspaceRoot(
workspaceRoot,
normalizedWorkspaceRoot,
"verify-created",
);
}
if (!workspaceStat) {
return yield* new WorkspaceRootNotExistsError({
workspaceRoot,
normalizedWorkspaceRoot,
});
}
if (workspaceStat.type !== "Directory") {
return yield* new WorkspaceRootNotDirectoryError({
workspaceRoot,
normalizedWorkspaceRoot,
});
}
return normalizedWorkspaceRoot;
});The project.create normalization path uses that root service and passes
createWorkspaceRootIfMissing through as the opt-in flag. The decider then
rejects a second active project whose comparison-normalized root is already
claimed. Thus “discover” in this chapter means validating and registering a root,
not crawling the disk and guessing every possible project.
const normalizeProjectWorkspaceRoot = (workspaceRoot: string) =>
workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(
Effect.mapError(
(cause) =>
new OrchestrationDispatchCommandError({
message: cause.message,
}),
),
);
const normalizeProjectWorkspaceRootForCreate = (
workspaceRoot: string,
createIfMissing: boolean | undefined,
) =>
workspacePaths
.normalizeWorkspaceRoot(workspaceRoot, {
createIfMissing: createIfMissing === true,
})
.pipe(
Effect.mapError(
(cause) =>
new OrchestrationDispatchCommandError({
message: cause.message,
}),
),
);
if (canonicalCommand.type === "project.create") {
return {
...canonicalCommand,
workspaceRoot: yield* normalizeProjectWorkspaceRootForCreate(
canonicalCommand.workspaceRoot,
canonicalCommand.createWorkspaceRootIfMissing,
),
createWorkspaceRootIfMissing: canonicalCommand.createWorkspaceRootIfMissing === true,
} satisfies OrchestrationCommand;export function requireActiveProjectWorkspaceRootAbsent(input: {
readonly readModel: OrchestrationReadModel;
readonly command: OrchestrationCommand;
readonly workspaceRoot: string;
readonly exceptProjectId?: ProjectId;
}): Effect.Effect<void, OrchestrationCommandInvariantError> {
const normalizedWorkspaceRoot = normalizeProjectPathForComparison(input.workspaceRoot);
const existingProject = input.readModel.projects.find(
(project) =>
project.deletedAt === null &&
normalizeProjectPathForComparison(project.workspaceRoot) === normalizedWorkspaceRoot &&
project.id !== input.exceptProjectId,
);
if (existingProject === undefined) {
return Effect.void;
}
return Effect.fail(
invariantError(
input.command.type,
`Active project '${existingProject.id}' already exists for workspace root '${normalizedWorkspaceRoot}'.`,
),
);
}Registration has explicit entry points, plus one configured startup shortcut
T3 does not recursively discover directories. The client add/browse path validates
one chosen path and builds project.create; t3 project add normalizes one CLI
argument and dispatches the same durable intent. Clone flows likewise resolve a
specific destination before registration. One deliberate exception to a person
clicking or typing a path is the optional server startup flag: when
autoBootstrapProjectFromCwd is enabled, startup registers the configured cwd when
needed and creates or selects an initial local thread. That is a single configured
root—not a disk crawl.
| Entry point | Candidate source | Durable effect | Discovery boundary |
|---|---|---|---|
| Client add / browse | one selected or typed path | builds project.create |
validates only that target |
CLI t3 project add |
one path argument | dispatches project.create |
rejects an already-registered normalized root |
| Clone flow | one resolved destination | registers the clone destination | no unrelated directory scan |
| Optional cwd bootstrap | configured server cwd | may create project plus first local thread | gated startup shortcut for one cwd |
t3.json is checked-in input, not the project database
The checked-in file is named t3.json at the workspace root. Its schema permits a
schema URL, a workspace-relative icon path, a default thread environment mode, and
up to 50 named scripts. A script has a display name and shell command; optional
fields select an icon, mark a script to run after worktree creation, and describe
desktop preview behavior. The schema says defaultThreadEnvMode is either local
or worktree; it does not make a non-Git folder capable of creating a Git worktree.
/** File name of the checked-in T3 project file, resolved at the workspace root. */
export const T3_PROJECT_FILE_NAME = "t3.json";
/** Public URL of the published JSON Schema for {@link T3ProjectFile}. */
export const T3_PROJECT_FILE_SCHEMA_URL = "https://t3.codes/schema/t3.json";
const T3_PROJECT_FILE_PATH_MAX_LENGTH = 512;
const T3_PROJECT_FILE_MAX_SCRIPTS = 50;
// Annotations go on the encoded (string) side so they survive into the
// published JSON Schema; decoding still trims and re-validates non-emptiness.
const trimmedNonEmpty = (annotations: { readonly description: string }, maxLength?: number) => {
const annotated = Schema.String.annotate(annotations);
const encoded =
maxLength === undefined
? annotated.check(Schema.isNonEmpty())
: annotated.check(Schema.isNonEmpty(), Schema.isMaxLength(maxLength));
return encoded.pipe(Schema.decodeTo(encoded, SchemaTransformation.trim()));
};
export const T3ProjectFileScript = Schema.Struct({
name: trimmedNonEmpty({
description: "Display name for the script, shown in the T3 Code scripts menu.",
}),
command: trimmedNonEmpty({
description: "Shell command executed in a T3 Code terminal at the project root.",
}),
icon: Schema.optionalKey(
ProjectScriptIcon.annotate({
description: 'Icon shown next to the script in the scripts menu. Defaults to "play".',
}),
),
runOnWorktreeCreate: Schema.optionalKey(
Schema.Boolean.annotate({
description:
"When true, the script runs automatically after a worktree is created for a new thread.",
}),
),
previewUrl: Schema.optionalKey(
trimmedNonEmpty({
description:
"URL opened in the in-app browser preview when this script runs. Only honored on the desktop build.",
}),
),
autoOpenPreview: Schema.optionalKey(
Schema.Boolean.annotate({
description:
"When true, automatically open the preview panel at `previewUrl` the moment the script starts.",
}),
),
}).annotate({
description: "A project script that team members can import into T3 Code.",
});
export type T3ProjectFileScript = typeof T3ProjectFileScript.Type;
export const T3ProjectFile = Schema.Struct({
$schema: Schema.optionalKey(
Schema.String.annotate({
description: `URL of the JSON Schema for this file, typically "${T3_PROJECT_FILE_SCHEMA_URL}".`,
}),
),
iconPath: Schema.optionalKey(
trimmedNonEmpty(
{
description:
'Workspace-relative path to the project icon (e.g. "assets/logo.svg"). Checked before T3 Code\'s built-in icon locations.',
},
T3_PROJECT_FILE_PATH_MAX_LENGTH,
),
),
defaultThreadEnvMode: Schema.optionalKey(
ThreadEnvMode.annotate({
description:
'Where new threads start for this repository: "worktree" for a fresh git worktree, "local" for the current checkout. A per-project setting in T3 Code overrides this; when neither is set, the global default applies.',
}),
),
scripts: Schema.optionalKey(
Schema.Array(T3ProjectFileScript)
.annotate({
description: "Project scripts shared with everyone who opens this repository in T3 Code.",
})
.check(Schema.isMaxLength(T3_PROJECT_FILE_MAX_SCRIPTS)),
),
}).annotate({
title: "T3 project file",
description:
"Checked-in project configuration for T3 Code (t3.json at the repository root). See https://t3.codes for documentation.",
});The loader joins that file name to the already-selected workspace root. A missing
file is normal; unreadable, malformed, and schema-invalid input is logged and
treated as absent. JSONC is accepted by the shared decoder, as the loader test
demonstrates. That makes t3.json advisory configuration with a safe fallback—not
an all-or-nothing prerequisite for opening a project.
/**
* Load and decode `t3.json` at the workspace root.
*
* Never fails: missing, unreadable, or invalid files resolve to
* `Option.none` (invalid files are logged as warnings).
*/
readonly load: (workspaceRoot: string) => Effect.Effect<Option.Option<T3ProjectFile>>;
}
>()("t3/project/T3ProjectFileLoader") {}
const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) =>
Effect.logWarning(error).pipe(
Effect.annotateLogs({
operation: error.operation,
workspaceRoot: error.workspaceRoot,
filePath: error.filePath,
errorTag: error._tag,
}),
);
export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const load: T3ProjectFileLoader["Service"]["load"] = Effect.fn("T3ProjectFileLoader.load")(
function* (workspaceRoot) {
const filePath = path.join(workspaceRoot, T3_PROJECT_FILE_NAME);
const raw = yield* fileSystem.readFileString(filePath).pipe(
Effect.map(Option.some),
Effect.catchTags({
PlatformError: (error) =>
error.reason._tag === "NotFound"
? Effect.succeed(Option.none<string>())
: logT3ProjectFileLoadError(
new T3ProjectFileLoadError({
operation: "read",
workspaceRoot,
filePath,
cause: error,
}),
).pipe(Effect.as(Option.none<string>())),
}),
);
if (Option.isNone(raw)) {
return Option.none<T3ProjectFile>();
}
return yield* decodeT3ProjectFileJson(raw.value).pipe(
Effect.map(Option.some),
Effect.catchTags({
SchemaError: (error) =>
logT3ProjectFileLoadError(
new T3ProjectFileLoadError({
operation: "decode",
workspaceRoot,
filePath,
cause: error,
}),
).pipe(Effect.as(Option.none<T3ProjectFile>())),
}),
);
},
);
return T3ProjectFileLoader.of({ load });it.layer(TestLayer)("T3ProjectFileLoader", (it) => {
describe("load", () => {
it.effect("loads and decodes a valid t3.json", () =>
Effect.gen(function* () {
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const cwd = yield* makeTempDir;
yield* writeProjectFile(
cwd,
`{
// JSONC is tolerated
"iconPath": "assets/logo.svg",
"scripts": [{ "name": "Dev", "command": "pnpm dev" }],
}`,
);
const loaded = yield* loader.load(cwd);
expect(Option.isSome(loaded)).toBe(true);
if (Option.isSome(loaded)) {
expect(loaded.value.iconPath).toBe("assets/logo.svg");
expect(loaded.value.scripts).toEqual([{ name: "Dev", command: "pnpm dev" }]);
}
}),
);
it.effect("returns none when t3.json is missing", () =>
Effect.gen(function* () {
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const cwd = yield* makeTempDir;
const loaded = yield* loader.load(cwd);
expect(Option.isNone(loaded)).toBe(true);
}),
);
it.effect("returns none for malformed JSON without failing", () =>
Effect.gen(function* () {
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const cwd = yield* makeTempDir;
yield* writeProjectFile(cwd, "{ not json");
const loaded = yield* loader.load(cwd);
expect(Option.isNone(loaded)).toBe(true);
}),
);
it.effect("returns none for schema-invalid files without failing", () =>
Effect.gen(function* () {
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const cwd = yield* makeTempDir;
yield* writeProjectFile(cwd, '{ "scripts": [{ "name": "Dev" }] }');
const loaded = yield* loader.load(cwd);
expect(Option.isNone(loaded)).toBe(true);
}),
);
});Precedence is specific to a new thread’s workspace choice
The shared resolver gives a per-project stored setting first priority, then the
checked-in t3.json value, then the global default. An explicit composer choice
outranks that resolver before it is consulted. The web settings surface states the
same project > t3.json > global order and lets null clear the project override.
This precedence does not mean that every t3.json field overrides every stored
project field: scripts are imported/stored separately, and the project record owns
its own selected script list.
/**
* Canonical priority order for a project's default thread env mode:
* per-project setting > checked-in t3.json > global server setting.
*
* An explicit composer pick outranks all of these; callers apply it before
* consulting the defaults. Web resolves the sources imperatively at draft
* creation, mobile reactively — both must route through this function so the
* platforms cannot disagree on the order.
*/
export function resolveDefaultThreadEnvMode(sources: {
readonly projectSetting: ThreadEnvMode | null | undefined;
readonly projectFile: ThreadEnvMode | null | undefined;
readonly globalDefault: ThreadEnvMode;
}): ThreadEnvMode {
return sources.projectSetting ?? sources.projectFile ?? sources.globalDefault;
}
/**
* True once the resolved default can no longer change: an explicit pick or a
* source that outranks t3.json decided, or the file read settled. While
* false, nothing may persist the provisional default (for example into a
* draft's workspace selection) — it could differ from the final value.
*/
export function isDefaultThreadEnvModeSettled(sources: {
readonly explicitMode: ThreadEnvMode | undefined;
readonly projectSetting: ThreadEnvMode | null | undefined;
readonly projectFilePending: boolean;
}): boolean {
return (
sources.explicitMode !== undefined ||
sources.projectSetting != null ||
!sources.projectFilePending
); const t3File = useT3ProjectFileState(
selectedCheckout.environmentId,
selectedCheckout.workspaceRoot,
);
// What the "Default" option resolves to while no override is set: the
// repo's t3.json value when present, otherwise the global setting.
const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode;
const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; <SettingsRow
title="Workspace"
description="Where new threads in this project start. Overrides t3.json and the global default; applies to every checkout in this group."
resetAction={
storedEnvMode !== null ? (
<SettingResetButton
label="project workspace default"
onClick={() => setDefaultThreadEnvMode(null)}
/>
) : null
}
control={
<Select
value={storedEnvMode ?? "inherit"}
onValueChange={(value) => {
if (value === "worktree" || value === "local") {
setDefaultThreadEnvMode(value);
} else if (value === "inherit") {
setDefaultThreadEnvMode(null);
}
}}
>
<SelectTrigger aria-label="New-thread workspace">
<SelectValue>
{storedEnvMode === null
? group.memberProjects.length > 1
? "Default (per checkout)"
: `Default (${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})`
: resolveEnvModeLabel(storedEnvMode)}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem value="inherit">
{group.memberProjects.length > 1
? "Default (each checkout's t3.json or global setting)"
: `Default (${inheritedEnvModeSource}: ${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})`}
</SelectItem>
<SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>
<SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>
</SelectPopup>Checked-in actions become durable scripts only after import
Raw t3.json actions are suggestions. The web scripts control excludes a suggestion
when an existing durable project script already has the same command or a
case-insensitive matching name. A person chooses an import; the UI converts the file
shape into a project script and updates the project’s stored scripts list. Later
setup selects from that durable list. The setup runner does not execute raw file
entries merely because a checkout contains t3.json.
const importableScripts = useMemo(
() =>
fileScripts.filter(
(fileScript) =>
!scripts.some(
(script) =>
script.command === fileScript.command ||
script.name.toLowerCase() === fileScript.name.toLowerCase(),
),
),
[fileScripts, scripts],
);
const dropdownItemClassName =
"data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground";
const openAddDialog = () => {
setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT });
};
const openEditDialog = (script: ProjectScript) => {
setActionsMenuOpen({ scripts: false, imports: false });
setEditorRequest(editorRequestForScript(script, keybindings));
};
const submitScript = useCallback(
(scriptId: string | null, input: NewProjectScriptInput) =>
scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input),
[onAddScript, onUpdateScript],
);
const importFileScript = async (fileScript: T3ProjectFileScript) => {
const payload: NewProjectScriptInput = {
name: fileScript.name,
command: fileScript.command,
icon: fileScript.icon ?? "play",
runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
keybinding: null,
previewUrl: fileScript.previewUrl ?? null,
autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
};
const result = await onAddScript(payload);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
// Surface the failure through the regular add dialog, prefilled so the
// user can adjust and retry.
const error = squashAtomCommandFailure(result);
setEditorRequest({
scriptId: null,
initial: payload,
error: error instanceof Error ? error.message : "Failed to import action.",
});
}
};
const importMenuItems = importableScripts.length > 0 && (
<>
{primaryScript && <MenuSeparator />}
<MenuGroup>
<MenuGroupLabel>From t3.json</MenuGroupLabel>
{importableScripts.map((fileScript) => (
<MenuItem
key={`${fileScript.name} ${fileScript.command}`}
className={dropdownItemClassName}
onClick={() => void importFileScript(fileScript)}
>
<ScriptIcon icon={fileScript.icon ?? "play"} className="size-4" />
<span className="truncate">{fileScript.name}</span>
<MenuShortcut className="ms-auto">
<DownloadIcon className="size-3.5" aria-label="Import" />
</MenuShortcut>
</MenuItem>
))}Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
Valid t3.json actions flow into a list of import suggestions. Existing project scripts remove duplicate command or name suggestions. A user import converts one suggestion and updates the durable project scripts list. The setup selector and runner read that durable list, not the raw t3.json action array.
Identity is resolved at read time from Git remotes
Repository identity resolution first asks Git for the top-level root, then reads
fetch remotes. It prefers upstream, then origin, then the lexicographically
first fetch remote. The selected URL is normalized into a canonical key and can
yield a display name, provider, owner, and repository name. A non-Git directory or
a Git repository with no remote returns null; positive and negative results are
cached for one minute by default.
function pickPrimaryRemote(
remotes: ReadonlyMap<string, string>,
): { readonly remoteName: string; readonly remoteUrl: string } | null {
for (const preferredRemoteName of ["upstream", "origin"] as const) {
const remoteUrl = remotes.get(preferredRemoteName);
if (remoteUrl) {
return { remoteName: preferredRemoteName, remoteUrl };
}
}
const [remoteName, remoteUrl] =
[...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? [];
return remoteName && remoteUrl ? { remoteName, remoteUrl } : null;
}
function buildRepositoryIdentity(input: {
readonly remoteName: string;
readonly remoteUrl: string;
readonly rootPath: string;
}): RepositoryIdentity {
const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl);
const repositoryPath = canonicalKey.split("/").slice(1).join("/");
const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0);
const [owner] = repositoryPathSegments;
const repositoryName = repositoryPathSegments.at(-1);
return {
canonicalKey,
locator: {
source: "git-remote",
remoteName: input.remoteName,
remoteUrl: input.remoteUrl,
},
rootPath: input.rootPath,
...(repositoryPath ? { displayName: repositoryPath } : {}),
...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}),
...(owner ? { owner } : {}),
...(repositoryName ? { name: repositoryName } : {}),
};
}
const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")(
function* (cwd: string) {
const processRunner = yield* ProcessRunner.ProcessRunner;
let cacheKey = cwd;
// git is a real executable on every platform — no cmd.exe shell mode, which
// would split paths containing spaces during cmd's re-tokenization.
const topLevelResult = yield* processRunner
.run({
command: "git",
args: ["-C", cwd, "rev-parse", "--show-toplevel"],
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) {
return cacheKey;
}
const candidate = topLevelResult.value.stdout.trim();
if (candidate.length > 0) {
cacheKey = candidate;
}
return cacheKey;
},
);
const resolveRepositoryIdentityFromCacheKey = Effect.fn(
"RepositoryIdentityResolver.resolveFromCacheKey",
)(function* (
cacheKey: string,
): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> {
const processRunner = yield* ProcessRunner.ProcessRunner;
const remoteResult = yield* processRunner
.run({
command: "git",
args: ["-C", cacheKey, "remote", "-v"],
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
if (remoteResult._tag === "None" || remoteResult.value.code !== 0) {
return null;
}
const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout));
return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;
});
export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
options: RepositoryIdentityResolverOptions = {},
) {
const processRunner = yield* ProcessRunner.ProcessRunner;
const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>(
(cacheKey) =>
resolveRepositoryIdentityFromCacheKey(cacheKey).pipe(
Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
),
{
capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY,
timeToLive: Exit.match({
onSuccess: (value) =>
value === null
? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL)
: (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL),
onFailure: () => Duration.zero,
}),
},
);
const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn(
"RepositoryIdentityResolver.resolve",
)(function* (cwd) {
const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe(
Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
);
return yield* Cache.get(repositoryIdentityCache, cacheKey);The snapshot query resolves identities for distinct active workspace roots with a bounded concurrency of four, then attaches the result to each returned project shell. The durable project projector itself starts with the project id, title, root, defaults, scripts, timestamps, and deletion state. In other words, a client receives a projection enriched by a fresh repository lookup; the event stream does not promise that a remote URL is permanently frozen into project history.
function mapProjectShellRow(
row: Schema.Schema.Type<typeof ProjectionProjectDbRowSchema>,
repositoryIdentity: OrchestrationProject["repositoryIdentity"],
): OrchestrationProjectShell {
return {
id: row.projectId,
title: row.title,
workspaceRoot: row.workspaceRoot,
repositoryIdentity,
defaultModelSelection: row.defaultModelSelection,
defaultThreadEnvMode: row.defaultThreadEnvMode,
faviconPath: row.faviconPath ?? null,
scripts: row.scripts,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function mapProposedPlanRow(
row: Schema.Schema.Type<typeof ProjectionThreadProposedPlanDbRowSchema>,
): OrchestrationProposedPlan {
return {
id: row.planId,
turnId: row.turnId,
planMarkdown: row.planMarkdown,
implementedAt: row.implementedAt,
implementationThreadId: row.implementationThreadId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
return (cause: unknown): ProjectionRepositoryError =>
Schema.isSchemaError(cause)
? toPersistenceDecodeError(decodeOperation)(cause)
: toPersistenceSqlError(sqlOperation)(cause);
}
const makeProjectionSnapshotQuery = Effect.gen(function* () {
const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
const threadPlanProgress = yield* ThreadPlanProgressService;
const sql = yield* SqlClient.SqlClient;
const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const repositoryIdentityResolutionConcurrency = 4;
const resolveRepositoryIdentitiesForProjects = Effect.fn(
"ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects",
)(function* (
projectRows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionProjectDbRowSchema>>,
options?: {
readonly includeDeleted?: boolean;
},
) {
const filteredProjectRows =
options?.includeDeleted === true
? projectRows
: projectRows.filter((row) => row.deletedAt === null);
const uniqueWorkspaceRoots = [...new Set(filteredProjectRows.map((row) => row.workspaceRoot))];
const repositoryIdentityByWorkspaceRoot = new Map(
yield* Effect.forEach(
uniqueWorkspaceRoots,
(workspaceRoot) =>
repositoryIdentityResolver
.resolve(workspaceRoot)
.pipe(Effect.map((identity) => [workspaceRoot, identity] as const)),
{ concurrency: repositoryIdentityResolutionConcurrency },
),
);
return new Map(
filteredProjectRows.map((row) => [
row.projectId,
repositoryIdentityByWorkspaceRoot.get(row.workspaceRoot) ?? null,
]),
);
}); case "project.create": {
yield* requireProjectAbsent({
readModel,
command,
projectId: command.projectId,
});
yield* requireActiveProjectWorkspaceRootAbsent({
readModel,
command,
workspaceRoot: command.workspaceRoot,
exceptProjectId: command.projectId,
});
return {
...(yield* withEventBase({
aggregateKind: "project",
aggregateId: command.projectId,
occurredAt: command.createdAt,
commandId: command.commandId,
})),
type: "project.created",
payload: {
projectId: command.projectId,
title: command.title,
workspaceRoot: command.workspaceRoot,
defaultModelSelection: command.defaultModelSelection ?? null,
faviconPath: null,
scripts: [],
createdAt: command.createdAt,
updatedAt: command.createdAt,
},
};
}
case "project.meta.update": {
yield* requireProject({
readModel,
command,
projectId: command.projectId,
});
if (command.workspaceRoot !== undefined) {
yield* requireActiveProjectWorkspaceRootAbsent({
readModel,
command,
workspaceRoot: command.workspaceRoot,
exceptProjectId: command.projectId,
});
}
const occurredAt = yield* nowIso;
return {
...(yield* withEventBase({
aggregateKind: "project",
aggregateId: command.projectId,
occurredAt,
commandId: command.commandId,
})),
type: "project.meta-updated",
payload: {
projectId: command.projectId,
...(command.title !== undefined ? { title: command.title } : {}),
...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}),
...(command.defaultModelSelection !== undefined
? { defaultModelSelection: command.defaultModelSelection }
: {}),
...(command.defaultThreadEnvMode !== undefined
? { defaultThreadEnvMode: command.defaultThreadEnvMode }
: {}),
...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}),
...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
updatedAt: occurredAt,
},
}; switch (event.type) {
case "project.created":
return decodeForEvent(ProjectCreatedPayload, event.payload, event.type, "payload").pipe(
Effect.map((payload) => {
const existing = nextBase.projects.find((entry) => entry.id === payload.projectId);
const nextProject = {
id: payload.projectId,
title: payload.title,
workspaceRoot: payload.workspaceRoot,
defaultModelSelection: payload.defaultModelSelection,
defaultThreadEnvMode: null,
faviconPath: payload.faviconPath ?? null,
scripts: payload.scripts,
createdAt: payload.createdAt,
updatedAt: payload.updatedAt,
deletedAt: null,
};
return {
...nextBase,
projects: existing
? nextBase.projects.map((entry) =>
entry.id === payload.projectId ? nextProject : entry,
)
: [...nextBase.projects, nextProject],
};
}),
);
case "project.meta-updated":
return decodeForEvent(ProjectMetaUpdatedPayload, event.payload, event.type, "payload").pipe(
Effect.map((payload) => ({
...nextBase,
projects: nextBase.projects.map((project) =>
project.id === payload.projectId
? {
...project,
...(payload.title !== undefined ? { title: payload.title } : {}),
...(payload.workspaceRoot !== undefined
? { workspaceRoot: payload.workspaceRoot }
: {}),
...(payload.defaultModelSelection !== undefined
? { defaultModelSelection: payload.defaultModelSelection }
: {}),
...(payload.defaultThreadEnvMode !== undefined
? { defaultThreadEnvMode: payload.defaultThreadEnvMode }
: {}),
...(payload.faviconPath !== undefined
? { faviconPath: payload.faviconPath }
: {}),
...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
updatedAt: payload.updatedAt,
}
: project,
),
})),
);Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
The diagram has a path-validation lane leading to project.create and a durable project read model. A separate Git remote lookup from the workspace root produces an optional repository identity, which enriches a snapshot. A client grouping step combines eligible physical project entries for presentation and annotates members with environment labels. The concrete project record remains the operational target.
apps/server/src/workspace/WorkspacePaths.ts:161–200 ↗apps/server/src/orchestration/decider.ts:227–298 ↗apps/server/src/orchestration/projector.ts:207–263 ↗apps/server/src/project/RepositoryIdentityResolver.ts:48–166 ↗apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:313–388 ↗packages/client-runtime/src/state/projectGrouping.ts:67–140 ↗apps/web/src/sidebarProjectGrouping.ts:53–115 ↗Grouping is presentation, and labels identify a member’s environment
Client grouping has three modes. separate uses a physical key of environment id
plus normalized workspace path. repository uses the canonical remote key when
available. The repository-and-path mode appends a workspace path relative to the
Git root when it can derive one. If identity is absent or relative-path derivation
fails, grouping falls back to the physical key or the canonical repository key as
the source says. The group retains member project references, so a grouped sidebar
title is never authorization to send a command to a vague “shared project.”
export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string {
return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`;
}
export function derivePhysicalProjectKey(
project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
): string {
return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot);
}
export function deriveProjectGroupingOverrideKey(
project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
): string {
return derivePhysicalProjectKey(project);
}
export function getProjectOrderKey(
project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
): string {
return derivePhysicalProjectKey(project);
}
export function resolveProjectGroupingMode(
project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
settings: ProjectGroupingSettings,
): SidebarProjectGroupingMode {
return (
settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ??
settings.sidebarProjectGroupingMode
);
}
function deriveRepositoryScopedKey(
project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
groupingMode: SidebarProjectGroupingMode,
): string | null {
const canonicalKey = project.repositoryIdentity?.canonicalKey;
if (!canonicalKey) {
return null;
}
if (groupingMode === "repository") {
return canonicalKey;
}
const relativeProjectPath = deriveRepositoryRelativeProjectPath(project);
if (relativeProjectPath === null) {
return canonicalKey;
}
return relativeProjectPath.length === 0
? canonicalKey
: `${canonicalKey}::${relativeProjectPath}`;
}
export function deriveLogicalProjectKey(
project: Pick<
EnvironmentProject,
"environmentId" | "id" | "workspaceRoot" | "repositoryIdentity"
>,
options?: {
readonly groupingMode?: SidebarProjectGroupingMode;
},
): string {
const groupingMode = options?.groupingMode ?? "repository";
if (groupingMode === "separate") {
return derivePhysicalProjectKey(project);
}
return (
deriveRepositoryScopedKey(project, groupingMode) ??
derivePhysicalProjectKey(project) ??
scopedProjectKey(scopeProjectRef(project.environmentId, project.id))
);The web sidebar decorates each retained member using an environment-label resolver, calculates local/remote/mixed presence against the primary environment, and keeps the member project refs. An environment label therefore tells the reader where a physical checkout lives; it is not part of the Git remote key and it does not alter the project root.
export function buildSidebarProjectSnapshots(input: {
projects: ReadonlyArray<Project>;
settings: ProjectGroupingSettings;
primaryEnvironmentId: EnvironmentId | null;
resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null;
// Returns true when an env id maps to a desktopLocal saved-env
// record (today: the WSL backend). Defaults to "false for every
// env" so callers that don't care about the distinction get the
// legacy behavior.
isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean;
}): SidebarProjectSnapshot[] {
return buildProjectGroups({
projects: input.projects,
settings: input.settings,
preferredEnvironmentId: input.primaryEnvironmentId,
}).map((group): SidebarProjectSnapshot => {
const members = group.members.map(
({ physicalProjectKey, project }): SidebarProjectGroupMember => ({
...project,
physicalProjectKey,
environmentLabel: input.resolveEnvironmentLabel(project.environmentId),
}),
);
const representative =
members.find(
(member) =>
member.environmentId === group.representative.environmentId &&
member.id === group.representative.id,
) ?? members[0]!;
const hasLocal =
input.primaryEnvironmentId !== null &&
members.some((member) => member.environmentId === input.primaryEnvironmentId);
const hasRemote =
input.primaryEnvironmentId !== null
? members.some((member) => member.environmentId !== input.primaryEnvironmentId)
: false;
const remoteMembers = members.filter(
(member) =>
input.primaryEnvironmentId !== null && member.environmentId !== input.primaryEnvironmentId,
);
const remoteEnvironmentLabels = remoteMembers
.flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : []))
.filter((label, index, labels) => labels.indexOf(label) === index);
const isDesktopLocal = input.isDesktopLocalEnvironment ?? (() => false);
const allRemoteMembersAreDesktopLocal =
remoteMembers.length > 0 &&
remoteMembers.every((member) => isDesktopLocal(member.environmentId));
return {
...representative,
projectKey: group.key,
displayName: group.label,
groupedProjectCount: members.length,
environmentPresence:
hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only",
allRemoteMembersAreDesktopLocal,
memberProjects: members,
memberProjectRefs: group.memberProjectRefs,
remoteEnvironmentLabels,
};
});
}Setup commands carry both roots into the new worktree terminal
The first script marked runOnWorktreeCreate is the setup script. When it runs,
its terminal cwd is the new worktree, while its environment sets
T3CODE_PROJECT_ROOT to the project’s original root and
T3CODE_WORKTREE_PATH to the new linked checkout. This is useful context for a
setup command; it is not an assertion that arbitrary child processes are confined
to that directory or that the two paths have the same Git state.
export function projectScriptCwd(input: {
project: {
cwd: string;
};
worktreePath?: string | null;
}): string {
return input.worktreePath ?? input.project.cwd;
}
export function projectScriptRuntimeEnv(
input: ProjectScriptRuntimeEnvInput,
): Record<string, string> {
const env: Record<string, string> = {
T3CODE_PROJECT_ROOT: input.project.cwd,
};
if (input.worktreePath) {
env.T3CODE_WORKTREE_PATH = input.worktreePath;
}
if (input.extraEnv) {
return { ...env, ...input.extraEnv };
}
return env;
}
export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null {
return scripts.find((script) => script.runOnWorktreeCreate) ?? null;
} if (!project) {
return yield* new ProjectSetupScriptProjectNotFoundError(errorContext);
}
const script = setupProjectScript(project.scripts);
if (!script) {
return {
status: "no-script",
} as const;
}
const terminalId = input.preferredTerminalId ?? `setup-${script.id}`;
const cwd = input.worktreePath;
const env = projectScriptRuntimeEnv({
project: { cwd: project.workspaceRoot },
worktreePath: input.worktreePath,
});
yield* terminalManager
.open({
threadId: input.threadId,
terminalId,
cwd,
worktreePath: input.worktreePath,
env,
})
.pipe(
Effect.mapError(
(cause) =>
new ProjectSetupScriptOperationError({
...errorContext,
operation: "openTerminal",
cause,
}),
),
);
yield* terminalManager
.write({
threadId: input.threadId,
terminalId,
data: `${script.command}\r`,
})
.pipe(
Effect.mapError(
(cause) =>
new ProjectSetupScriptOperationError({
...errorContext,
operation: "writeCommand",
cause,
}),
),
);
return {
status: "started",
scriptId: script.id,
scriptName: script.name,
terminalId,
cwd,
} as const;
});Work the filesystem decision by hand
The lab does not touch your files. It makes the boundary visible: a valid root, a durable project record, usable checked-in configuration, and a Git identity can coexist—but none automatically proves the next one.
What becomes a T3 project?
Choose one observed filesystem state. The four answers keep root validation, durable projection, checked-in configuration, and Git identity separate.
Selected: Existing directory, valid t3.json
- 1. Root
- Normalize the supplied path and verify it is a directory.
- 2. Project
- A project.create command can claim this normalized root if no active project already uses it.
- 3. t3.json
- Decode t3.json at that root; its defaultThreadEnvMode can participate only after a project-level override is absent.
- 4. Identity
- If Git has a fetch remote, resolve a canonical repository identity for projection and cross-environment grouping.
All filesystem outcomes
- Existing directory, valid t3.json
Root: Normalize the supplied path and verify it is a directory.
Project: A project.create command can claim this normalized root if no active project already uses it.
t3.json: Decode t3.json at that root; its defaultThreadEnvMode can participate only after a project-level override is absent.
Identity: If Git has a fetch remote, resolve a canonical repository identity for projection and cross-environment grouping.
- Missing directory, creation not requested
Root: Normalization reports that the workspace root does not exist.
Project: No project is created because the command cannot pass root validation.
t3.json: There is no root at which to load t3.json.
Identity: No repository identity is derived.
- Missing directory, creation requested
Root: The root service creates the directory recursively, verifies it, then returns the normalized path.
Project: The create command still must satisfy the one-active-project-per-normalized-root invariant.
t3.json: A missing t3.json is an ordinary absence, not an error.
Identity: A newly created empty directory has no Git-remote identity until it becomes a qualifying repository.
- Existing regular file
Root: Normalization rejects it: a workspace root must be a directory.
Project: No project projection changes.
t3.json: t3.json is never consulted as project configuration for this invalid root.
Identity: No identity is derived.
- Directory with invalid t3.json
Root: The workspace root itself remains valid.
Project: The durable project record can still exist at the root.
t3.json: The best-effort loader logs the read/decode problem and presents no usable file configuration, so defaults fall through.
Identity: Repository identity is independent of t3.json validity.