Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import {

describe("DesktopEarlyElectronStartup", () => {
const joinPath = NodePath.posix.join;
const noDirectories = () => false;

it("reads the persisted linux password-store preference before Electron is ready", () => {
const preference = resolveEarlyLinuxPasswordStorePreference({
env: { T3CODE_HOME: "/home/user/.t3-test" },
homeDirectory: "/home/user",
joinPath,
directoryExists: noDirectories,
readFileString: (path) => {
assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json");
return JSON.stringify({ linuxPasswordStore: "kwallet6" });
Expand All @@ -29,6 +31,7 @@ describe("DesktopEarlyElectronStartup", () => {
env: { T3CODE_HOME: "/home/user/.t3-test" },
homeDirectory: "/home/user",
joinPath,
directoryExists: noDirectories,
readFileString: () => `{
// manually edited setting
"linuxPasswordStore": "gnome-libsecret",
Expand All @@ -43,6 +46,7 @@ describe("DesktopEarlyElectronStartup", () => {
env: {},
homeDirectory: "/home/user",
joinPath,
directoryExists: noDirectories,
readFileString: () => {
throw new Error("missing");
},
Expand All @@ -56,6 +60,7 @@ describe("DesktopEarlyElectronStartup", () => {
env: { T3CODE_HOME: "/" },
homeDirectory: "/home/user",
joinPath,
directoryExists: noDirectories,
readFileString: (path) => {
assert.equal(path, "/userdata/desktop-settings.json");
return JSON.stringify({ linuxPasswordStore: "kwallet6" });
Expand All @@ -74,6 +79,7 @@ describe("DesktopEarlyElectronStartup", () => {
},
homeDirectory: "/home/user",
joinPath,
directoryExists: noDirectories,
readFileString: (path) => {
assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json");
return JSON.stringify({ linuxPasswordStore: "auto" });
Expand All @@ -88,15 +94,16 @@ describe("DesktopEarlyElectronStartup", () => {
});
});

it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => {
it("keeps implicit development state under the Linux XDG default when T3CODE_HOME is unset", () => {
const preference = resolveEarlyLinuxPasswordStorePreference({
env: {
VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
},
homeDirectory: "/home/user",
joinPath,
directoryExists: noDirectories,
readFileString: (path) => {
assert.equal(path, "/home/user/.t3/dev/desktop-settings.json");
assert.equal(path, "/home/user/.local/share/t3code/dev/desktop-settings.json");
return JSON.stringify({ linuxPasswordStore: "kwallet" });
},
});
Expand All @@ -112,8 +119,9 @@ describe("DesktopEarlyElectronStartup", () => {
},
homeDirectory: "/home/user",
joinPath,
directoryExists: noDirectories,
readFileString: (path) => {
assert.equal(path, "/home/user/.t3/dev/desktop-settings.json");
assert.equal(path, "/home/user/.local/share/t3code/dev/desktop-settings.json");
return JSON.stringify({ linuxPasswordStore: "gnome-libsecret" });
},
});
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/app/DesktopEarlyElectronStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface EarlyDesktopSettingsInput {
readonly env: NodeJS.ProcessEnv;
readonly homeDirectory: string;
readonly joinPath: JoinPath;
readonly directoryExists: (path: string) => boolean;
readonly readFileString: (path: string) => string;
}

Expand Down Expand Up @@ -53,12 +54,16 @@ function resolveEarlyDesktopSettingsPath(input: {
readonly env: NodeJS.ProcessEnv;
readonly homeDirectory: string;
readonly joinPath: JoinPath;
readonly directoryExists: (path: string) => boolean;
}): string {
const t3Home = Option.fromUndefinedOr(input.env.T3CODE_HOME);
const baseDir = resolveDesktopBaseDir({
homeDirectory: input.homeDirectory,
joinPath: input.joinPath,
platform: "linux",
t3Home,
xdgDataHome: Option.fromUndefinedOr(input.env.XDG_DATA_HOME),
directoryExists: input.directoryExists,
});
const stateDir = resolveDesktopStateDir({
baseDir,
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,23 @@ describe("DesktopEnvironment", () => {
}),
);

it.effect("uses XDG_DATA_HOME for implicit Linux state", () =>
Effect.gen(function* () {
const environment = yield* makeEnvironment(
{
platform: "linux",
homeDirectory: "/home/alice",
},
{
XDG_DATA_HOME: "/data",
},
);

assert.equal(environment.baseDir, "/data/t3code");
assert.equal(environment.stateDir, "/data/t3code/userdata");
}),
);

it.effect("keeps implicit development state separate from production state", () =>
Effect.gen(function* () {
const development = yield* makeEnvironment(
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
DesktopRuntimeArch,
DesktopRuntimeInfo,
} from "@t3tools/contracts";
import { directoryExistsSync } from "@t3tools/shared/defaultT3Home";
import * as Config from "effect/Config";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -160,7 +161,10 @@ const make = Effect.fn("desktop.environment.make")(function* (
const baseDir = resolveDesktopBaseDir({
homeDirectory,
joinPath: path.join,
platform: input.platform,
t3Home: config.t3Home,
xdgDataHome: config.xdgDataHome,
directoryExists: directoryExistsSync,
});
const rootDir = path.resolve(input.dirname, "../../..");
const appRoot = input.isPackaged ? input.appPath : rootDir;
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/app/DesktopPreReadyPlatform.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings and prepares the Linux desktop entry synchronously before app services are available.
import { directoryExistsSync } from "@t3tools/shared/defaultT3Home";
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
Expand Down Expand Up @@ -37,6 +38,7 @@ export const resolveEarlyLinuxElectronOptionsFromProcess =
env: process.env,
homeDirectory: NodeOS.homedir(),
joinPath: NodePath.posix.join,
directoryExists: directoryExistsSync,
readFileString: (path) => NodeFS.readFileSync(path, "utf8"),
});

Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/app/DesktopStatePaths.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { defaultT3Home } from "@t3tools/shared/defaultT3Home";
import * as Option from "effect/Option";

export type JoinPath = (first: string, ...segments: string[]) => string;
Expand All @@ -13,10 +14,19 @@ function normalizeConfiguredBaseDir(t3Home: Option.Option<string>): Option.Optio
export function resolveDesktopBaseDir(input: {
readonly homeDirectory: string;
readonly joinPath: JoinPath;
readonly platform: NodeJS.Platform;
readonly t3Home: Option.Option<string>;
readonly xdgDataHome: Option.Option<string>;
readonly directoryExists: (path: string) => boolean;
}): string {
return Option.getOrElse(normalizeConfiguredBaseDir(input.t3Home), () =>
input.joinPath(input.homeDirectory, ".t3"),
defaultT3Home({
platform: input.platform,
homeDirectory: input.homeDirectory,
xdgDataHome: Option.getOrUndefined(input.xdgDataHome),
joinPath: input.joinPath,
directoryExists: input.directoryExists,
}),
);
}

Expand Down
21 changes: 13 additions & 8 deletions apps/server/scripts/migrate-dev-db.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env node

/**
* Rebuild an isolated dev database from a pruned snapshot of the real
* ~/.t3 database, then run this checkout's migrations against it.
* Rebuild an isolated dev database from a pruned snapshot of the default
* T3 database, then run this checkout's migrations against it.
*
* `vp run migrate-dev-db` from a worktree:
* 1. Nukes `<worktree>/.t3/userdata/state.sqlite`.
Expand All @@ -26,7 +26,8 @@

import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeOS from "node:os";
import { defaultT3HomeForHost } from "@t3tools/shared/defaultT3Home";
import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -54,7 +55,7 @@ export class MigrateDevDbSharedHomeError extends Schema.TaggedError<MigrateDevDb
{},
) {
override get message(): string {
return "Refusing to rebuild the shared ~/.t3 database. Use an isolated --base-dir.";
return "Refusing to rebuild the shared T3 database. Use an isolated --base-dir.";
}
}

Expand Down Expand Up @@ -143,7 +144,7 @@ export class MigrateDevDbPhaseError extends Schema.TaggedError<MigrateDevDbPhase
export interface RunMigrateDevDbInput {
/** Isolated .t3 directory. Defaults to `<worktree>/.t3` of the cwd. */
readonly baseDir?: string | undefined;
/** Source database. Defaults to `~/.t3/userdata/state.sqlite`. */
/** Source database. Defaults to `<default T3 home>/userdata/state.sqlite`. */
readonly source?: string | undefined;
readonly projects: number;
readonly threadsPerProject: number;
Expand Down Expand Up @@ -360,7 +361,11 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3"));
const sharedHome = path.resolve(options.sharedHome ??
defaultT3HomeForHost({
platform: yield* HostProcessPlatform,
env: yield* HostProcessEnvironment,
}));
const sourcePath = path.resolve(
input.source ?? path.join(sharedHome, "userdata", "state.sqlite"),
);
Expand Down Expand Up @@ -520,7 +525,7 @@ export const migrateDevDbCommand = Command.make(
),
source: Flag.string("source").pipe(
Flag.optional,
Flag.withDescription("Source database. Defaults to ~/.t3/userdata/state.sqlite."),
Flag.withDescription("Source database. Defaults to <default T3 home>/userdata/state.sqlite."),
),
},
({ projects, threadsPerProject, baseDir, source }) =>
Expand All @@ -547,7 +552,7 @@ export const migrateDevDbCommand = Command.make(
}),
).pipe(
Command.withDescription(
"Rebuild the worktree dev database from a pruned snapshot of the real ~/.t3 data, then run migrations.",
"Rebuild the worktree dev database from a pruned snapshot of the default T3 data, then run migrations.",
),
);

Expand Down
11 changes: 8 additions & 3 deletions apps/server/scripts/t3-sqlite-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeOS from "node:os";
import { defaultT3HomeForHost } from "@t3tools/shared/defaultT3Home";
import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
import * as Console from "effect/Console";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -62,7 +63,7 @@ export class SqliteStateSharedHomeMutationError extends Schema.TaggedError<Sqlit
{},
) {
override get message(): string {
return "Refusing to mutate the shared ~/.t3 database. Use an isolated --base-dir.";
return "Refusing to mutate the shared T3 database. Use an isolated --base-dir.";
}
}

Expand Down Expand Up @@ -181,7 +182,11 @@ export const runSqliteState = Effect.fn("runSqliteState")(function* (
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = path.resolve(input.baseDir);
const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3"));
const sharedHome = path.resolve(options.sharedHome ??
defaultT3HomeForHost({
platform: yield* HostProcessPlatform,
env: yield* HostProcessEnvironment,
}));
const databasePath = path.join(baseDir, "userdata", "state.sqlite");
const source = yield* resolveSqlSource(input.sql, input.file);

Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/os-jank.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { defaultT3HomeForHost } from "@t3tools/shared/defaultT3Home";
import {
listLoginShellCandidates,
mergePathEntries,
Expand Down Expand Up @@ -103,9 +104,12 @@ export const expandHomePath = Effect.fn(function* (input: string) {
});

export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) {
const { join, resolve } = yield* Path.Path;
const { resolve } = yield* Path.Path;
if (!raw || raw.trim().length === 0) {
return join(NodeOS.homedir(), ".t3");
return defaultT3HomeForHost({
platform: yield* HostProcessPlatform,
env: yield* HostProcessEnvironment,
});
}
return resolve(yield* expandHomePath(raw.trim()));
});
19 changes: 12 additions & 7 deletions apps/server/src/project/AgentSessionScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,13 +850,18 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
const codexHomePath = yield* makeTempDir("t3code-codex-home-");
const fileSystem = yield* FileSystem.FileSystem;

const worktreeCwd = path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1");
yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true });
yield* writeTranscript({
filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
contents: claudeSessionLine(worktreeCwd),
mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
});
const worktreeCwds = [
path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1"),
path.join(claudeHomePath, ".local", "share", "t3code", "worktrees", "wt-2"),
];
for (const [index, worktreeCwd] of worktreeCwds.entries()) {
yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true });
yield* writeTranscript({
filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"),
contents: claudeSessionLine(worktreeCwd),
mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") + index,
});
}

const result = yield* runScan({ claudeHomePath, codexHomePath });

Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/project/AgentSessionScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ function shouldRetainDecodedRecord(
* T3 Code runs its own agent sessions inside disposable worktrees. Their
* transcripts look exactly like user sessions, but re-importing the app's own
* sandboxes as projects is never right. Matches this server's configured
* worktrees directory plus the conventional `.t3/worktrees` layout, which
* worktrees directory plus the conventional default T3 home layouts, which
* also catches sandboxes from other T3 homes on the same machine. Separators
* are normalized (and, on Windows, case folded) so the prefix match holds
* there too. Callers check both the recorded spelling and its realpath so a
Expand All @@ -558,7 +558,8 @@ function isT3ManagedWorktree(
const normalized = normalizeForWorktreeMatch(candidatePath, caseFold);
return (
normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) ||
normalized.includes("/.t3/worktrees/")
normalized.includes("/.t3/worktrees/") ||
normalized.includes("/t3code/worktrees/")
);
}

Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/telemetry/Identify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ const upsertAnonymousId = Effect.gen(function* () {
* getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value.
* 1. ~/.codex/auth.json tokens.account_id
* 2. ~/.claude.json userID
* 3. ~/.t3/telemetry/anonymous-id
* 3. <T3 state dir>/anonymous-id
*/
export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierForHome")(
function* (homeDirectory: string) {
Expand Down
6 changes: 4 additions & 2 deletions docs/operations/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ Add `--browser` to open a browser automatically.
### State and ports

Linked worktrees default to their own `.t3/userdata`, even when `T3CODE_HOME` is set.
The main checkout defaults to `~/.t3/dev/userdata`. An explicit `--home-dir` wins in both cases.
Never run a development server against the live `~/.t3/userdata`.
The main checkout defaults to `<T3 home>/dev/userdata`. On Linux, T3 home defaults to
`${XDG_DATA_HOME:-$HOME/.local/share}/t3code`, unless only legacy `~/.t3` exists; other platforms use
`~/.t3`. An explicit `--home-dir` wins in both cases.
Never run a development server against the live `<T3 home>/userdata`.
See [test data](../../AGENTS.md#test-data) for copying a consistent database snapshot.

Read ports from the `[dev-runner]` output. Worktrees derive stable preferences from their paths,
Expand Down
Loading