Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
abed954
refactor: remove in-product Relay orchestration
rynfar Oct 3, 2026
4eacff5
feat(contracts): adopt v2 schemas and shared client runtime
rynfar Oct 3, 2026
eed7e97
feat(server): adopt orchestrator v2 and bridge Prime
rynfar Oct 3, 2026
4843d5a
feat(web): adopt v2 conversation and provider flows
rynfar Oct 3, 2026
1b3b595
feat(mobile): adopt v2 orchestration and native composer
rynfar Oct 3, 2026
69eb2fa
feat(desktop): isolate v2 profiles and provider auth
rynfar Oct 3, 2026
4509dbe
docs: describe v2 behavior and initial provider limits
rynfar Oct 3, 2026
a03ad16
build: align v2 dependencies and packaging
rynfar Oct 3, 2026
3780d9c
test(server): adapt v2 boundaries and Pylon branding
rynfar Oct 3, 2026
9aa5e64
fix: resolve v2 integration remnants and model eligibility
rynfar Oct 3, 2026
7b388d9
fix(server): align v2 fixtures and usage freshness
rynfar Oct 3, 2026
3e98c96
fix(mobile): retain context usage meter on v2
rynfar Oct 3, 2026
a3608de
fix(web): preserve v2 handoff and resolve UI regressions
rynfar Oct 3, 2026
f78cd91
fix: preserve Pylon UI and resolve v2 export checks
rynfar Oct 3, 2026
8e13114
refactor(server): retire unused v1 helpers
rynfar Oct 3, 2026
2d816af
fix(server): retain OpenCode browser tool timeout
rynfar Oct 3, 2026
c0f096a
fix(server): prove Prime cleanup before maintenance
rynfar Oct 3, 2026
3900f1d
fix(web): preserve handoff routing and failed drafts
rynfar Oct 3, 2026
8da587a
style: format remaining v2 integration files
rynfar Oct 3, 2026
603f8ab
docs: clarify initial v2 Prime capabilities
rynfar Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .agents/references/product-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The desktop product identity is deliberately independent from T3 Code so both ap
- macOS/Windows application ID: `com.pylon.code` (`com.pylon.code.dev.*` for local development);
- renderer protocols: `pylon-code://` and `pylon-code-dev://`;
- runtime home: `~/.pylon-code` unless explicitly overridden;
- Electron profiles: `pylon-code` and `pylon-code-dev`;
- Electron profiles: `pylon-code-v2`, `pylon-code-nightly-v2`, and `pylon-code-dev`; the v2 profiles isolate Chromium state from v1, while Windows migrates only the safeStorage `Local State` key from the prior channel profile;
- Linux executable/registration: `pylon`, the per-channel desktop entries `com.pylon.code.desktop`, `com.pylon.code.nightly.desktop` and `com.pylon.code.dev.desktop`, and the matching `com.pylon.code[.nightly|.dev]` WM class and Wayland app ID (Electron derives both from the desktop entry, and XDG portals require a dotted ID);
- packaged app and artifacts: `Pylon (Alpha)` / `Pylon (Nightly)` and `Pylon-*`.

Expand Down
17 changes: 14 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,9 @@ jobs:
T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json
run: vp run --fail-if-no-match --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }}

# src/server.test.ts writes the budget report, so exactly one shard
# produces these files. Gating on their presence keeps a single
# `thread-transfer-results` artifact per run, which is the name
# integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard
# produces these files. Gating the upload on their presence keeps a
# single `thread-transfer-results` artifact per run, which is the name
# thread-transfer-report.yml resolves.
- name: Detect transfer budget report
id: transfer_budget
Expand Down Expand Up @@ -338,6 +338,17 @@ jobs:
if-no-files-found: ignore
retention-days: 30

transfer-report:
name: Transfer report artifact
needs: test_server
runs-on: ubuntu-24.04
steps:
- name: Require transfer measurements
uses: actions/download-artifact@v8
with:
name: thread-transfer-results
path: ${{ runner.temp }}/thread-transfer

# Split out of Check and Test: both paid ~7-9s to install a Rust toolchain
# for checks that take under 3s, on the critical path of every PR.
rust:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/mobile-eas-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ on:
workflow_dispatch:
inputs:
mode:
description: "build (+ auto-submit to TestFlight) or update (OTA)"
description: "build (+ auto-submit to TestFlight / Google Play) or update (OTA)"
required: true
type: choice
default: build
Expand All @@ -20,7 +20,7 @@ on:
description: "Target platform"
required: true
type: choice
default: ios
default: all
options:
- ios
- android
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ squashfs-root/
.vercel
.gstack/
.plans/
/audits/
dist-electron/
.electron-runtime/
.showcase/
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [Permission modes](./docs/user/permission-modes.md)
- [Keyboard shortcuts](./docs/user/keybindings.md)
- [Project settings](./docs/user/project-settings.md)
- [Appearance preferences](./docs/user/appearance.md)
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
- [Keeping app and server in sync](./docs/user/updating.md)
- [Source control integrations](./docs/user/source-control.md)
Expand Down
113 changes: 113 additions & 0 deletions apps/desktop/src/app/CodexAuthCallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Tests exercise the real native loopback listener without an OpenAI account.
import * as NodeHttp from "node:http";
import { describe, expect, it } from "vite-plus/test";
import { codexAuthDeliveryUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff";
import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
import { receiveCodexAuthCallback, cancelCodexAuthCallback } from "./CodexAuthCallback.ts";

async function freePort() {
const server = NodeHttp.createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("address");
await new Promise<void>((resolve) => server.close(() => resolve()));
return address.port;
}
function request(port: number, state = "a".repeat(43)) {
const url = new URL("https://auth.openai.com/api/accounts/authorize");
url.search = new URLSearchParams({
client_id: "dynamic_agent_client",
response_type: "code",
redirect_uri: `http://127.0.0.1:${port}/auth/callback`,
state,
code_challenge_method: "S256",
code_challenge: "b".repeat(43),
}).toString();
return url.toString();
}
function callback(authorizationUrl: string) {
const request = new URL(authorizationUrl);
const url = new URL(request.searchParams.get("redirect_uri")!);
url.search = new URLSearchParams({
state: request.searchParams.get("state")!,
code: "test-code",
client_id: "oaiapp_test",
}).toString();
return url.toString();
}

describe("desktop Codex callback helper", () => {
it("binds before opening sign-in, ignores a foreign response, and returns only the code callback", async () => {
const authorizationUrl = request(await freePort());
const expected = callback(authorizationUrl);
const received = await receiveCodexAuthCallback(authorizationUrl, async () => {
const invalid = new URL(expected);
invalid.searchParams.set("state", "foreign");
expect((await fetch(invalid)).status).toBe(400);
const response = await fetch(expected);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.text()).not.toContain("test-code");
return true;
});
expect(received).toBe(expected);
});
it("returns hosted web to the exact instance and environment without putting the code in its query", async () => {
const authorizationUrl = request(await freePort());
const expected = callback(authorizationUrl);
const input = {
authorizationUrl,
returnUrl:
"https://app.pylon-code.com/settings/providers?environmentId=remote-one&instanceId=work",
environmentId: EnvironmentId.make("remote-one"),
instanceId: ProviderInstanceId.make("work"),
flowId: "flow-one",
};
await receiveCodexAuthCallback(
authorizationUrl,
async () => {
const response = await fetch(expected, { redirect: "manual" });
expect(response.status).toBe(303);
const delivery = response.headers.get("location")!;
expect(new URL(delivery).searchParams.has("code")).toBe(false);
expect(readCodexAuthDelivery(delivery)?.callbackUrl).toBe(expected);
expect(readCodexAuthDelivery(delivery)?.returnUrl).toBe(input.returnUrl);
return true;
},
(url) => codexAuthDeliveryUrl(input, url),
);
});
it("cancels and releases its listener so exact-port reauthorization can run again", async () => {
const authorizationUrl = request(await freePort());
await expect(
receiveCodexAuthCallback(authorizationUrl, async () => {
cancelCodexAuthCallback(authorizationUrl);
return true;
}),
).rejects.toThrow("cancelled");
expect(
await receiveCodexAuthCallback(authorizationUrl, async () => {
await fetch(callback(authorizationUrl));
return true;
}),
).toBe(callback(authorizationUrl));
});
it("allows two accounts to complete independently", async () => {
const a = request(await freePort(), "a".repeat(43));
const b = request(await freePort(), "c".repeat(43));
const openedA = Promise.withResolvers<void>();
const openedB = Promise.withResolvers<void>();
const receiveA = receiveCodexAuthCallback(a, async () => {
openedA.resolve();
await openedB.promise;
await fetch(callback(a));
return true;
});
const receiveB = receiveCodexAuthCallback(b, async () => {
openedB.resolve();
await openedA.promise;
await fetch(callback(b));
return true;
});
expect(await Promise.all([receiveA, receiveB])).toEqual([callback(a), callback(b)]);
});
});
5 changes: 5 additions & 0 deletions apps/desktop/src/app/CodexAuthCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
CodexAuthCallbackError,
cancelCodexAuthCallback,
receiveCodexAuthCallback,
} from "@t3tools/shared/codexAuthCallback";
38 changes: 25 additions & 13 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
import * as DesktopAssets from "./DesktopAssets.ts";
import * as DesktopConfig from "./DesktopConfig.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopUserData from "./DesktopUserData.ts";

const defaultEnvironmentInput = {
dirname: "/repo/apps/desktop/dist-electron",
Expand Down Expand Up @@ -125,12 +126,15 @@ const withIdentity = <A, E, R>(
return effect.pipe(
Effect.provide(
DesktopAppIdentity.layer.pipe(
Layer.provide(NodePath.layerPosix),
Layer.provideMerge(
FileSystem.layerNoop({
exists: (path) =>
input.legacyPathProbeError
? Effect.fail(input.legacyPathProbeError)
: Effect.succeed(input.legacyPathExists === true && path.includes("Pylon (Alpha)")),
: Effect.succeed(
input.legacyPathExists === true && /Pylon \((Alpha|Dev)\)/.test(path),
),
readFileString: () =>
Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'),
}),
Expand All @@ -144,31 +148,36 @@ const withIdentity = <A, E, R>(
};

describe("DesktopAppIdentity", () => {
it.effect("uses Pylon's independent Electron profile for a fresh install", () =>
it.effect("isolates the V2 profile even when the legacy V1 profile exists", () =>
withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
const userDataPath = yield* identity.resolveUserDataPath;

assert.equal(userDataPath, "/Users/alice/Library/Application Support/pylon-code");
assert.equal(userDataPath, "/Users/alice/Library/Application Support/pylon-code-v2");
}),
{ legacyPathExists: true },
),
);

it.effect("keeps using the legacy userData path when it already exists", () =>
it.effect("keeps using the legacy development profile", () =>
withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
const userDataPath = yield* identity.resolveUserDataPath;

assert.equal(userDataPath, "/Users/alice/Library/Application Support/Pylon (Alpha)");
assert.equal(
yield* identity.resolveUserDataPath,
"/Users/alice/Library/Application Support/Pylon (Dev)",
);
}),
{ legacyPathExists: true },
{
legacyPathExists: true,
environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } },
},
),
);

it.effect("preserves failures while inspecting the legacy userData path", () => {
const legacyPath = "/Users/alice/Library/Application Support/Pylon (Alpha)";
const legacyPath = "/Users/alice/Library/Application Support/Pylon (Dev)";
const cause = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
Expand All @@ -182,15 +191,18 @@ describe("DesktopAppIdentity", () => {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
const error = yield* identity.resolveUserDataPath.pipe(Effect.flip);

assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathResolutionError);
assert.equal(error.legacyPath, legacyPath);
assert.instanceOf(error, DesktopUserData.DesktopUserDataInitializationError);
assert.equal(error.resourcePath, legacyPath);
assert.strictEqual(error.cause, cause);
assert.equal(
error.message,
`Failed to inspect legacy desktop user-data path at "${legacyPath}".`,
`Could not initialize Electron user data during inspect at ${legacyPath} (PermissionDenied).`,
);
}),
{ legacyPathProbeError: cause },
{
legacyPathProbeError: cause,
environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } },
},
);
});

Expand Down
47 changes: 9 additions & 38 deletions apps/desktop/src/app/DesktopAppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";

import * as ElectronApp from "../electron/ElectronApp.ts";
import * as DesktopAssets from "./DesktopAssets.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopUserData from "./DesktopUserData.ts";

const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;
const COMMIT_HASH_DISPLAY_LENGTH = 12;
Expand All @@ -18,22 +20,13 @@ const AppPackageMetadata = Schema.Struct({
});
const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata));

export class DesktopUserDataPathResolutionError extends Schema.TaggedError<DesktopUserDataPathResolutionError>()(
"DesktopUserDataPathResolutionError",
{
legacyPath: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to inspect legacy desktop user-data path at "${this.legacyPath}".`;
}
}

export class DesktopAppIdentity extends Context.Service<
DesktopAppIdentity,
{
readonly resolveUserDataPath: Effect.Effect<string, DesktopUserDataPathResolutionError>;
readonly resolveUserDataPath: Effect.Effect<
string,
DesktopUserData.DesktopUserDataInitializationError
>;
readonly configure: Effect.Effect<void>;
}
>()("@t3tools/desktop/app/DesktopAppIdentity") {}
Expand All @@ -45,33 +38,13 @@ const normalizeCommitHash = (value: string): Option.Option<string> => {
: Option.none();
};

export const resolveUserDataPath = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const legacyPath = environment.path.join(
environment.appDataDirectory,
environment.legacyUserDataDirName,
);
const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe(
Effect.mapError(
(cause) =>
new DesktopUserDataPathResolutionError({
legacyPath,
cause,
}),
),
);
return legacyPathExists
? legacyPath
: environment.path.join(environment.appDataDirectory, environment.userDataDirName);
}).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath"));

/** @public Service construction is part of the canonical Effect module API. */
export const make = Effect.gen(function* () {
const assets = yield* DesktopAssets.DesktopAssets;
const electronApp = yield* ElectronApp.ElectronApp;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const userDataContext = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
const commitHashCache = yield* Ref.make<Option.Option<Option.Option<string>>>(Option.none());

const resolveEmbeddedCommitHash = Effect.gen(function* () {
Expand Down Expand Up @@ -112,10 +85,8 @@ export const make = Effect.gen(function* () {
return commitHash;
});

const userDataPath = resolveUserDataPath.pipe(
Effect.provide(
yield* Effect.context<DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem>(),
),
const userDataPath = DesktopUserData.resolveUserDataPath(environment).pipe(
Effect.provide(userDataContext),
);

const configure = Effect.gen(function* () {
Expand Down
Loading
Loading