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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ target/
dist/
dist-electron/
/release/
/.artifacts/
.vite-plus/
*.tsbuildinfo
/.worktrees/
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ T4 Code is a free, open-source (MIT) desktop app for [Oh My Pi](https://github.c

T4 Code needs an OMP build with desktop appserver support. For v0.1.24, use the public integration build below.

T4 Code v0.1.24 was verified with OMP 17.0.4 built from [`d57dcd85`](https://github.com/lyc-aon/oh-my-pi/commit/d57dcd855006c673d8d530237d474fe5ba5645c4), tagged [`t4code-17.0.4-appserver-5`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.4-appserver-5). That public integration is based on the official upstream [`v17.0.4`](https://github.com/can1357/oh-my-pi/tree/v17.0.4) tag at [`3fdd85ab`](https://github.com/can1357/oh-my-pi/commit/3fdd85ab6c6bab6c0cdee80abbbec0981740a5c0). It adds redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, macOS system-temp aliases, workspace-native build artifacts, retry-safe release metadata, lock-aware session observation, complete transcript reconciliation, missing-lock-only promotion, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.4 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source. T4 Code vendors `@oh-my-pi/app-wire` 0.6.0 from integration commit [`ae4b53b4`](https://github.com/lyc-aon/oh-my-pi/commit/ae4b53b416f32b200865a32ed9baabd5a4666fa4), source tree `2b8a5f697273f5044789b8ae638b6c264f9f8499`.
T4 Code v0.1.24 was verified with OMP 17.0.5 built from [`3393ae0f`](https://github.com/lyc-aon/oh-my-pi/commit/3393ae0f7fc5b2ea9919d8bdb3a2d5719b1cbc2f), tagged [`t4code-17.0.5-appserver-3`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-3). That public integration is based on the official upstream [`v17.0.5`](https://github.com/can1357/oh-my-pi/tree/v17.0.5) tag at [`9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). It adds faster appserver startup, cross-session attention and transcript search, redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, macOS system-temp aliases, workspace-native build artifacts, retry-safe release metadata, lock-aware session observation, complete transcript reconciliation, missing-lock-only promotion, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.5 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source. T4 Code vendors `@oh-my-pi/app-wire` 0.6.1 from integration commit [`e3e15c03`](https://github.com/lyc-aon/oh-my-pi/commit/e3e15c03ae95ebbda5f26495cd21213cc53518b1), source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`.

The current source tree advances the vendored contract to `@oh-my-pi/app-wire` 0.6.1 from integration commit [`e3e15c03`](https://github.com/lyc-aon/oh-my-pi/commit/e3e15c03ae95ebbda5f26495cd21213cc53518b1), source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`. This adds the bounded cross-session transcript search and historical context contract.

Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/src/bundled-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { createHash, randomUUID } from "node:crypto";
import { chmod, copyFile, mkdir, readFile, rename, stat, unlink } from "node:fs/promises";
import { join } from "node:path";

export interface BundledRuntimeManifest {
readonly version: 1;
readonly tag: string;
readonly platform: "darwin";
readonly arch: "arm64";
readonly executable: "omp";
readonly size: number;
readonly sha256: string;
}

function decodeManifest(value: unknown): BundledRuntimeManifest {
const record = value as Partial<BundledRuntimeManifest> | null;
if (
record?.version !== 1 ||
record.platform !== "darwin" ||
record.arch !== "arm64" ||
record.executable !== "omp" ||
typeof record.tag !== "string" ||
!/^t4code-[0-9]+\.[0-9]+\.[0-9]+-appserver-[1-9][0-9]*$/u.test(record.tag) ||
!Number.isSafeInteger(record.size) ||
(record.size ?? 0) < 1 ||
typeof record.sha256 !== "string" ||
!/^[0-9a-f]{64}$/u.test(record.sha256)
) throw new Error("bundled OMP runtime manifest is invalid");
return record as BundledRuntimeManifest;
}

async function matches(path: string, manifest: BundledRuntimeManifest): Promise<boolean> {
try {
if ((await stat(path)).size !== manifest.size) return false;
const hash = createHash("sha256").update(await readFile(path)).digest("hex");
return hash === manifest.sha256;
} catch {
return false;
}
}

export async function installBundledOmpRuntime(options: {
readonly resourcesPath: string;
readonly applicationSupportPath: string;
}): Promise<string> {
const sourceRoot = join(options.resourcesPath, "runtime");
const manifest = decodeManifest(JSON.parse(await readFile(join(sourceRoot, "manifest.json"), "utf8")));
const source = join(sourceRoot, manifest.executable);
if (!(await matches(source, manifest))) throw new Error("bundled OMP runtime failed its integrity check");
const destinationRoot = join(options.applicationSupportPath, "runtime", manifest.tag);
const destination = join(destinationRoot, "omp");
if (await matches(destination, manifest)) {
await chmod(destination, 0o755);
return destination;
}
await mkdir(destinationRoot, { recursive: true, mode: 0o700 });
const temporary = join(destinationRoot, `.omp-${randomUUID()}.partial`);
try {
await copyFile(source, temporary);
await chmod(temporary, 0o755);
if (!(await matches(temporary, manifest))) throw new Error("installed OMP runtime failed its integrity check");
await rename(temporary, destination);
} finally {
await unlink(temporary).catch(() => {});
}
return destination;
}
22 changes: 22 additions & 0 deletions apps/desktop/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
decodeDesktopUpdateState,
decodeProjectionCacheLoadResult,
decodeProjectionCacheSaveResult,
decodePhoneSetupState,
type BootstrapResult,
type CommandRequest,
type CommandResult,
Expand All @@ -30,6 +31,7 @@ import {
type PairLinkEvent,
type PairRequest,
type PairResult,
type PhoneSetupState,
type PairLinksDrainResult,
type ProjectionCacheLoadResult,
type ProjectionCacheSaveRequest,
Expand Down Expand Up @@ -81,6 +83,10 @@ export interface IpcRuntime {
readonly load: () => ProjectionCacheLoadResult | Promise<ProjectionCacheLoadResult>;
readonly save: (value: string) => ProjectionCacheSaveResult | Promise<ProjectionCacheSaveResult>;
};
readonly phoneSetup?: {
readonly inspect: () => Promise<PhoneSetupState>;
readonly configure: () => Promise<PhoneSetupState>;
};
}
export class RemotePairingUnavailableError extends Error {
readonly code = "remote_pairing_unavailable" as const;
Expand Down Expand Up @@ -258,6 +264,16 @@ export class DesktopIpcRegistry {
decodeRequest("app:update:get-state", payload);
return decodeDesktopUpdateState(this.updateController().getState());
});
this.ipc.handle("app:phone-setup:inspect", async (event, payload: unknown): Promise<PhoneSetupState> => {
this.assertSender(event);
decodeRequest("app:phone-setup:inspect", payload);
return decodePhoneSetupState(await this.phoneSetup().inspect());
});
this.ipc.handle("app:phone-setup:configure", async (event, payload: unknown): Promise<PhoneSetupState> => {
this.assertSender(event);
decodeRequest("app:phone-setup:configure", payload);
return decodePhoneSetupState(await this.phoneSetup().configure());
});
this.ipc.handle("app:update:check", async (event, payload: unknown): Promise<DesktopUpdateState> => {
this.assertSender(event);
decodeRequest("app:update:check", payload);
Expand Down Expand Up @@ -296,6 +312,11 @@ export class DesktopIpcRegistry {
this.emit("app:update:state", state);
});
}

private phoneSetup(): NonNullable<IpcRuntime["phoneSetup"]> {
if (!this.runtime.phoneSetup) throw new Error("Phone setup is unavailable in this build.");
return this.runtime.phoneSetup;
}
uninstall(): void {
this.updateUnsubscribe?.();
this.updateUnsubscribe = undefined;
Expand All @@ -311,6 +332,7 @@ export class DesktopIpcRegistry {
"app:update:get-state", "app:update:check", "app:update:download", "app:update:restart",
"app:update:renderer-ready",
"app:projection-cache:load", "app:projection-cache:save",
"app:phone-setup:inspect", "app:phone-setup:configure",
] as const) this.ipc.removeHandler(channel);
this.installed = false;
}
Expand Down
25 changes: 22 additions & 3 deletions apps/desktop/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { installApplicationMenu, type ApplicationMenuOptions } from "./menu.ts";
import { DesktopUpdateController } from "./update-controller.ts";
import { LocalProfileRegistry } from "./local-profiles.ts";
import { LocalProfileRuntime } from "./profile-runtime.ts";
import { installBundledOmpRuntime } from "./bundled-runtime.ts";
import { PhoneSetupService } from "./phone-setup.ts";

type ProjectionCacheRuntime = NonNullable<IpcRuntime["projectionCache"]>;

Expand Down Expand Up @@ -101,6 +103,7 @@ export class DesktopLifecycle {
private readonly serviceRecoveryPromises = new Map<string, Promise<ServiceManager | undefined>>();
private readonly serviceAvailabilityIssues = new Map<string, ServiceAvailabilityIssue>();
private updateController: DesktopUpdateController | undefined;
private phoneSetup: PhoneSetupService | undefined;
private pendingUpdateOpen = false;
private rendererLoaded = false;
private updateRendererReady = false;
Expand All @@ -127,7 +130,15 @@ export class DesktopLifecycle {
() => new LocalProfileRegistry(new ElectronLocalProfileStore())
);
this.projectionCacheFactory = options.createProjectionCache ?? (() => new ElectronProjectionCacheStore());
this.executableFactory = options.discoverExecutable ?? (() => discoverOmpExecutable());
this.executableFactory = options.discoverExecutable ?? (async () => {
if (this.electronApp.isPackaged && process.platform === "darwin" && process.arch === "arm64") {
return installBundledOmpRuntime({
resourcesPath: process.resourcesPath,
applicationSupportPath: this.electronApp.getPath("userData"),
});
}
return discoverOmpExecutable();
});
this.appserverProbe = options.probeAppserver ?? ((executable) => probeOmpAppserver(executable));
this.serviceFactory = options.createServiceManager ?? createAppserverServiceManager;
this.targetManagerFactory = options.createTargetManager ?? ((managerOptions) => new LocalTargetManager(managerOptions));
Expand Down Expand Up @@ -174,13 +185,17 @@ export class DesktopLifecycle {
this.projectionCache = this.projectionCacheFactory();
if (process.platform === "darwin") this.electronApp.setAsDefaultProtocolClient("t4-code");
this.updateController = this.updateControllerFactory();
if (this.electronApp.isPackaged) {
this.phoneSetup = new PhoneSetupService({
resourcesPath: process.resourcesPath,
electronExecutable: process.execPath,
});
}
this.menuInstaller({ onOpenUpdates: () => this.openUpdatesFromMenu() });
const identity = this.identityFactory();
const remoteRegistry = this.remoteRegistryFactory();
const credentials = this.credentialsFactory();
this.localProfileRegistry = this.localProfileRegistryFactory();
await this.acquireServiceManager();
if (this.stopping) return;
this.manager = this.targetManagerFactory({
cursorStore: this.cursorStoreFactory(),
registry: remoteRegistry,
Expand All @@ -205,6 +220,8 @@ export class DesktopLifecycle {
discoverExecutable: () => this.discoverServiceExecutable(),
});
this.bindWindow(this.windowFactory());
await this.acquireServiceManager();
if (this.stopping) return;
await this.profileRuntime.startAutomaticProfiles((profileId, error) => {
this.ipc?.emitRuntimeError(runtimeError(error, `local:${profileId}`));
});
Expand Down Expand Up @@ -234,6 +251,7 @@ export class DesktopLifecycle {
this.mainWindow = undefined;
this.updateController?.dispose();
this.updateController = undefined;
this.phoneSetup = undefined;
this.projectionCache = undefined;
const manager = this.manager;
this.manager = undefined;
Expand Down Expand Up @@ -435,6 +453,7 @@ export class DesktopLifecycle {
drainPairLinks: () => this.pendingPairs.drain(),
drainPendingUpdateOpen: () => this.markUpdateRendererReady(),
...(this.updateController === undefined ? {} : { updateController: this.updateController }),
...(this.phoneSetup === undefined ? {} : { phoneSetup: this.phoneSetup }),
});
this.ipc.install();
handle.window.webContents.on("did-start-loading", () => {
Expand Down
Loading
Loading