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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ body:
id: version
attributes:
label: T4 Code version
placeholder: "0.1.11"
placeholder: "0.1.12"
validations:
required: true
- type: dropdown
Expand Down
42 changes: 22 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,35 @@ T4 Code is a free, open-source (MIT) desktop app for [Oh My Pi](https://github.c

![T4 Code main window](docs/assets/t4-code-main.png)

[**Download v0.1.11**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.11) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source)
[**Download v0.1.12**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.12) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source)

## Requirements

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

T4 Code v0.1.11 was verified with OMP 16.5.1 built from [`15527d1f`](https://github.com/lyc-aon/oh-my-pi/commit/15527d1f00bac22705f63f80b29c0c30e67fc5da), tagged [`t4code-16.5.1-appserver-1`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.1-appserver-1). That public integration is based on the official upstream [`v16.5.1`](https://github.com/can1357/oh-my-pi/tree/v16.5.1) tag at [`14b5da76`](https://github.com/can1357/oh-my-pi/commit/14b5da76a9aece9a469288718d22c3d624daf033). It carries forward bounded replay and terminal events, complete desktop runtime projection, catalog-backed session management, deterministic failed-worker reaping, recoverable crash state, settled close state, ordered remote delivery, cross-client control convergence, and restart-safe session teardown. It also reconciles OMP 16.5.1's RPC disconnect cleanup with T4's persistent session-lock release. The official upstream v16.5.1 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 does not depend on private home-directory files, an auth broker, or a custom Codex CLI fork. T4 Code vendors `@oh-my-pi/app-wire` 0.5.3 from integration commit [`15527d1f`](https://github.com/lyc-aon/oh-my-pi/commit/15527d1f00bac22705f63f80b29c0c30e67fc5da), source tree `4961ea9c522a3bbf9a9900424dd475a48148c729`.
T4 Code v0.1.12 was verified with OMP 16.5.1 built from [`6a87fa64`](https://github.com/lyc-aon/oh-my-pi/commit/6a87fa6407ebff20417b4d52885a6bb3091003ea), tagged [`t4code-16.5.1-appserver-4`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.1-appserver-4). That public integration is based on the official upstream [`v16.5.1`](https://github.com/can1357/oh-my-pi/tree/v16.5.1) tag at [`14b5da76`](https://github.com/can1357/oh-my-pi/commit/14b5da76a9aece9a469288718d22c3d624daf033). It carries forward T4's appserver and lifecycle integration, adds bounded prompt-image upload and transcript-image readback, and tightens public wire-frame decoding, typed command results, canonical response boundaries, and RPC event bounds. The official upstream v16.5.1 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 does not depend on private home-directory files, an auth broker, or a custom Codex CLI fork. T4 Code vendors `@oh-my-pi/app-wire` 0.5.5 from integration commit [`6a87fa64`](https://github.com/lyc-aon/oh-my-pi/commit/6a87fa6407ebff20417b4d52885a6bb3091003ea), source tree `a2495fe8781c979184fe7fb9a6d37d8f33bad30f`.

| Platform | Arch | Package |
| --- | --- | --- |
| Android | arm64, armv7, x86_64 | `.apk` (**signed**) |
| Linux | x86_64 | `.deb`, AppImage |
| macOS | Apple Silicon (arm64) | `.dmg`, `.zip` (**unsigned, see below**) |
| Platform | Arch | Package |
| -------- | --------------------- | ---------------------------------------- |
| Android | arm64, armv7, x86_64 | `.apk` (**signed**) |
| Linux | x86_64 | `.deb`, AppImage |
| macOS | Apple Silicon (arm64) | `.dmg`, `.zip` (**unsigned, see below**) |

No Windows build and no Intel Mac build in v0.1.11. The iOS TestFlight build is coming soon.
No Windows build and no Intel Mac build in v0.1.12. The iOS TestFlight build is coming soon.

## What changed in v0.1.11
## What changed in v0.1.12

- The verified host moves to official OMP 16.5.1. It brings interrupted-turn recovery, organization-scoped Anthropic credentials, complete credential rotation, correct subagent model selection, and bounded transcript retention.
- The integration settles pending extension UI and host requests before it drains RPC work and releases the persistent session lock. app-wire remains at 0.5.3 with the same source tree and packaged bytes.
- Attach up to eight PNG, JPEG, WebP, or GIF images to a prompt, with a 20 MiB limit per image. Drafts remain recoverable when an upload or connection is interrupted.
- Transcript images are read back by verified digest through bounded queues and caches. Animated images expose pause and play controls, and reduced-motion preferences are respected.
- Desktop, browser, and Android connections now time out and retry cleanly when a host accepts a socket but never completes setup. A one-shot compatibility handshake also keeps older hosts usable when they reject the new image feature.
- The matching OMP runtime adds bounded image upload/read commands, strict public frame decoding, and bounded RPC terminal and agent metadata.

## Install

### Android

1. On the Android phone, sign in to Tailscale with an account that can reach the T4 Code host.
2. Download [`T4-Code-0.1.11-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.11/T4-Code-0.1.11-android.apk).
2. Download [`T4-Code-0.1.12-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.12/T4-Code-0.1.12-android.apk).
3. If Android asks, allow your browser or file manager to install unknown apps, then install the APK.
4. Open T4 Code and enter the host's HTTPS Tailscale address, including its port.

Expand All @@ -39,26 +41,26 @@ The APK does not contain an appserver or expose one to the public internet. It c
### Linux (Debian/Ubuntu)

```sh
wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.11/T4-Code-0.1.11-linux-amd64.deb
sudo apt install ./T4-Code-0.1.11-linux-amd64.deb
wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.12/T4-Code-0.1.12-linux-amd64.deb
sudo apt install ./T4-Code-0.1.12-linux-amd64.deb
```

Use `apt install` rather than `dpkg -i` so system dependencies resolve automatically.

### Linux (AppImage)

```sh
wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.11/T4-Code-0.1.11-linux-x86_64.AppImage
chmod +x T4-Code-0.1.11-linux-x86_64.AppImage
./T4-Code-0.1.11-linux-x86_64.AppImage
wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.12/T4-Code-0.1.12-linux-x86_64.AppImage
chmod +x T4-Code-0.1.12-linux-x86_64.AppImage
./T4-Code-0.1.12-linux-x86_64.AppImage
```

### macOS (Apple Silicon)

> [!WARNING]
> **The macOS v0.1.11 build is unsigned and unnotarized.** Apple has not signed or notarized it, so Gatekeeper can report a "damaged" app or an unidentified developer. Only continue if you trust the release from this repository. You can always build from source instead.
> **The macOS v0.1.12 build is unsigned and unnotarized.** Apple has not signed or notarized it, so Gatekeeper can report a "damaged" app or an unidentified developer. Only continue if you trust the release from this repository. You can always build from source instead.

1. Download [`T4-Code-0.1.11-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.11/T4-Code-0.1.11-mac-arm64.dmg) (or [`T4-Code-0.1.11-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.11/T4-Code-0.1.11-mac-arm64.zip)).
1. Download [`T4-Code-0.1.12-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.12/T4-Code-0.1.12-mac-arm64.dmg) (or [`T4-Code-0.1.12-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.12/T4-Code-0.1.12-mac-arm64.zip)).
2. Drag `T4 Code.app` into `/Applications`.
3. If Gatekeeper blocks the app and you choose to proceed, remove the quarantine attributes from the copied app bundle:

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ We read every report and will reply to tell you what happens next. This is a sma

- T4 Code is a desktop client. The OMP runtime is a separate project; runtime vulnerabilities belong at <https://github.com/can1357/oh-my-pi>.
- Pairing credentials are encrypted with the OS keychain via Electron `safeStorage`. Reports about credential handling, the pairing flow, or the `t4-code://` deep-link handler are especially welcome.
- The macOS v0.1.11 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app.
- The macOS v0.1.12 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app.
2 changes: 1 addition & 1 deletion THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ T3 Code is selectively referenced for future ports from https://github.com/pingd

## Oh My Pi

Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.5.3` package is packed from the public `lyc-aon/oh-my-pi` integration commit `15527d1f00bac22705f63f80b29c0c30e67fc5da`, source tree `4961ea9c522a3bbf9a9900424dd475a48148c729`; tarball SHA-256 `5ae8833b963a6a56b65a9e2184358906a8717a6c8f57617875a91d4771791050`; golden corpus SHA-256 `36811f39241c6c491c967a8f969f14c43431366289750538a40893d0dc267324`. Target integration commit is recorded in the Desktop commit history and compatibility matrix.
Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.5.5` package is packed from the public `lyc-aon/oh-my-pi` integration commit `6a87fa6407ebff20417b4d52885a6bb3091003ea`, source tree `a2495fe8781c979184fe7fb9a6d37d8f33bad30f`; tarball SHA-256 `049d92d47c0c29a019c3844072b96198105116c452a54182163ef78994ef557e`; golden corpus SHA-256 `e92d3d7a4848ab6ea6403cc1c1faa6912f8fdc75d2a6abf663ece0154a6eb7fa`. Target integration commit is recorded in the Desktop commit history and compatibility matrix.

## Oh My Pi icon

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@t4-code/desktop",
"version": "0.1.11",
"version": "0.1.12",
"private": true,
"type": "module",
"main": "dist-electron/main.cjs",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/security-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function rendererUrl(options: { readonly isPackaged: boolean; readonly de
export function contentSecurityPolicy(trusted: TrustedRenderer, development: boolean): string {
const script = development ? "'self' 'unsafe-inline'" : "'self'";
const connect = development ? `connect-src ${trusted.origin}` : "connect-src 'none'";
return ["default-src 'self'", `script-src ${script}`, "style-src 'self' 'unsafe-inline'", "font-src 'self' data:", "img-src 'self' data:", connect, "object-src 'none'", "base-uri 'none'", "frame-src 'none'", "frame-ancestors 'none'", "form-action 'none'"].join("; ");
return ["default-src 'self'", `script-src ${script}`, "style-src 'self' 'unsafe-inline'", "font-src 'self' data:", "img-src 'self' data: blob:", connect, "object-src 'none'", "base-uri 'none'", "frame-src 'none'", "frame-ancestors 'none'", "form-action 'none'"].join("; ");
}

export function isTrustedNavigation(value: string, trusted: TrustedRenderer): boolean {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function contentSecurityPolicy(trusted: TrustedRenderer, development: boo
`script-src ${script}`,
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"img-src 'self' data:",
"img-src 'self' data: blob:",
connect,
"object-src 'none'",
"base-uri 'none'",
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/target-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import { createLocalTransport, type UnixWebSocketTransport } from "./transport.t
import { createRemoteWebSocketTransport, type RemoteWebSocketTransport } from "./remote-runtime/transport.ts";
import { validateRemoteTarget, type CredentialStore, type PublicRemoteTarget, type RemoteTargetRecord, type RemoteTargetRegistry } from "./remote-runtime/registry.ts";
const DEFAULT_CAPABILITIES: readonly DeviceCapability[] = Object.freeze([...DEVICE_CAPABILITIES]);
const REQUESTED_FEATURES: readonly string[] = ADDITIVE_FEATURES;
const COMPATIBILITY_FEATURES: readonly string[] = Object.freeze(
REQUESTED_FEATURES.filter(
(feature) => feature !== "prompt.images" && feature !== "transcript.images",
),
);

export type DesktopTargetState = "disconnected" | "connecting" | "connected" | "pairing-required" | "error";
export interface PublicDesktopTarget {
Expand Down Expand Up @@ -331,8 +337,9 @@ export class DesktopTargetManager {
}),
cursorStore: this.cursorStoreFactory(targetId),
capabilities: requestedCapabilities,
requestedFeatures: ADDITIVE_FEATURES,
client: { name: "T4 Code", version: "0.1.11", build: "desktop", platform: process.platform },
requestedFeatures: REQUESTED_FEATURES,
compatibilityRequestedFeatures: COMPATIBILITY_FEATURES,
client: { name: "T4 Code", version: "0.1.12", build: "desktop", platform: process.platform },
reconnect: { attemptCap: 12, baseMs: 250, maxMs: 10_000 },
};
const client = createOmpClient(clientOptions);
Expand Down
41 changes: 37 additions & 4 deletions apps/desktop/src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,22 +67,32 @@ export function ensureMacRuntimeDirectory(path: string): void {
export interface UnixWebSocketTransportOptions {
readonly socketPath: string;
readonly validatePath?: boolean;
readonly handshakeTimeoutMs?: number;
}

export class UnixWebSocketTransport implements OmpTransport {
private readonly socketPath: string;
private readonly shouldValidate: boolean;
private readonly handshakeTimeoutMs: number;
private socket: WebSocket | undefined;
private readonly messages = new Set<(data: string | Uint8Array) => void>();
private readonly closes = new Set<(code?: number, reason?: string) => void>();
private readonly errors = new Set<(error: unknown) => void>();
private closed = false;
private openReject: (() => void) | undefined;
private openTimer: ReturnType<typeof setTimeout> | undefined;

constructor(options: UnixWebSocketTransportOptions) {
if (!options.socketPath.startsWith("/")) throw new Error("Unix socket path must be absolute");
if (
options.handshakeTimeoutMs !== undefined &&
(!Number.isSafeInteger(options.handshakeTimeoutMs) || options.handshakeTimeoutMs <= 0)
) {
throw new Error("handshake timeout must be a positive safe integer");
}
this.socketPath = options.socketPath;
this.shouldValidate = options.validatePath ?? true;
this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000;
}

open(): Promise<void> {
Expand All @@ -92,14 +102,36 @@ export class UnixWebSocketTransport implements OmpTransport {
const socket = new WebSocket("ws://omp.local/ws", {
perMessageDeflate: false,
maxPayload: 1_048_576,
handshakeTimeout: this.handshakeTimeoutMs + 100,
createConnection: () => netConnect({ path: socketPath }),
});
this.socket = socket;
const { promise, resolve, reject } = Promise.withResolvers<void>();
let settled = false;
this.openReject = () => { if (!settled) { settled = true; reject(new Error("local transport closed")); } };
const succeed = (): void => { if (!settled) { settled = true; this.openReject = undefined; resolve(); } };
const fail = (): void => { if (!settled) { settled = true; this.openReject = undefined; reject(new Error("local transport unavailable")); } };
const clearOpenTimer = (): void => {
if (this.openTimer === undefined) return;
clearTimeout(this.openTimer);
this.openTimer = undefined;
};
const fail = (message = "local transport unavailable"): void => {
if (settled) return;
settled = true;
clearOpenTimer();
this.openReject = undefined;
reject(new Error(message));
};
this.openReject = () => fail("local transport closed");
const succeed = (): void => {
if (settled) return;
settled = true;
clearOpenTimer();
this.openReject = undefined;
resolve();
};
this.openTimer = setTimeout(() => {
fail("local websocket handshake timed out");
if (socket.readyState === WebSocket.CONNECTING) socket.terminate();
}, this.handshakeTimeoutMs);
socket.on("open", succeed);
socket.on("message", (data, isBinary) => {
if (isBinary) return;
Expand Down Expand Up @@ -128,6 +160,8 @@ export class UnixWebSocketTransport implements OmpTransport {
this.closed = true;
this.openReject?.();
this.openReject = undefined;
if (this.openTimer !== undefined) clearTimeout(this.openTimer);
this.openTimer = undefined;
const socket = this.socket;
this.socket = undefined;
if (socket !== undefined) {
Expand Down Expand Up @@ -159,4 +193,3 @@ export function createLocalTransport(): UnixWebSocketTransport {
if (process.platform === "darwin") ensureMacRuntimeDirectory(dirname(socketPath));
return new UnixWebSocketTransport({ socketPath });
}

3 changes: 3 additions & 0 deletions apps/desktop/test/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe("desktop security boundaries", () => {
expect(contentSecurityPolicy(trusted, false)).not.toContain("ws://127.0.0.1:5173");
for (const policy of [development, contentSecurityPolicy(trusted, false)]) {
expect(policy).toContain("font-src 'self' data:");
expect(policy).toContain("img-src 'self' data: blob:");
}
const production = contentSecurityPolicy(trusted, false);
expect(production).toContain("script-src 'self'");
Expand All @@ -33,6 +34,8 @@ describe("desktop security boundaries", () => {
const duplicateProduction = duplicateContentSecurityPolicy({ origin: trusted.origin, url: trusted.url }, false);
expect(duplicateDevelopment).toContain("font-src 'self' data:");
expect(duplicateProduction).toContain("font-src 'self' data:");
expect(duplicateDevelopment).toContain("img-src 'self' data: blob:");
expect(duplicateProduction).toContain("img-src 'self' data: blob:");
expect(duplicateProduction).toContain("script-src 'self'");
expect(duplicateProduction).not.toContain("script-src 'self' 'unsafe-inline'");
expect(duplicateProduction).toContain("connect-src 'none'");
Expand Down
Loading
Loading