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 apps/launcher/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"types": "./src/index.ts",
"dependencies": {
"@codex-git/host-adapter": "*",
"@codex-git/host-adapter-codex-cdp": "*",
"@codex-git/host-adapter-standalone": "*",
"@codex-git/server": "*"
},
Expand Down
114 changes: 114 additions & 0 deletions apps/launcher/src/codex-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { HostConnection } from '@codex-git/host-adapter';
import {
connectDedicatedCodexRenderer,
DedicatedCodexHostAdapter,
launchDedicatedCodexInstance,
type ConnectDedicatedRenderer,
type DedicatedCodexInstance,
type LaunchDedicatedCodexOptions,
} from '@codex-git/host-adapter-codex-cdp';

import {
startStandaloneRuntime,
type StandaloneRuntime,
type StandaloneRuntimeOptions,
} from './standalone-runtime.js';

export interface CodexRuntimeOptions extends StandaloneRuntimeOptions {
readonly connectRenderer?: ConnectDedicatedRenderer;
readonly dedicatedInstance?: LaunchDedicatedCodexOptions;
readonly launchInstance?: (
options?: LaunchDedicatedCodexOptions,
) => Promise<DedicatedCodexInstance>;
readonly projectPath: string;
}

export interface CodexRuntime extends StandaloneRuntime {
currentHost(): 'codex' | 'standalone';
}

export async function startCodexRuntime(
options: CodexRuntimeOptions,
): Promise<CodexRuntime> {
const standalone = await startStandaloneRuntime(options);
let instance: DedicatedCodexInstance | null = null;
let connection: HostConnection | null = null;
let host: 'codex' | 'standalone' = 'standalone';
let closing = false;
let monitor = Promise.resolve();

try {
instance = await (options.launchInstance ?? launchDedicatedCodexInstance)(
options.dedicatedInstance,
);
const result = await new DedicatedCodexHostAdapter({
connectRenderer: options.connectRenderer ?? connectDedicatedCodexRenderer,
instance,
projectPath: options.projectPath,
}).attach({
title: 'Codex Git',
url: standalone.surfaceUrl,
});
if (result.kind === 'standalone-required') {
await instance.close();
instance = null;
} else {
connection = result.connection;
host = 'codex';
monitor = monitorTransitions(connection, async () => {
if (closing) {
return;
}
host = 'standalone';
const attachedConnection = connection;
const dedicatedInstance = instance;
connection = null;
instance = null;
await Promise.allSettled([
attachedConnection?.close(),
dedicatedInstance?.close(),
]);
});
}
} catch {
await instance?.close().catch(() => undefined);
instance = null;
}

return {
healthUrl: standalone.healthUrl,
surfaceUrl: standalone.surfaceUrl,
currentHost: () => host,
async close() {
if (closing) {
return;
}
closing = true;
const results = await Promise.allSettled([
connection?.close(),
instance?.close(),
monitor,
standalone.close(),
]);
const failure = results.find(
(result): result is PromiseRejectedResult =>
result.status === 'rejected',
);
if (failure !== undefined) {
throw failure.reason;
}
},
};
}

async function monitorTransitions(
connection: HostConnection,
fallback: () => Promise<void>,
): Promise<void> {
for await (const transition of connection.transitions()) {
if (transition.kind === 'standalone-required') {
await fallback();
return;
}
}
}
1 change: 1 addition & 0 deletions apps/launcher/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './codex-runtime.js';
export * from './standalone-runtime.js';
8 changes: 6 additions & 2 deletions apps/launcher/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { startStandaloneRuntime } from './index.js';
import { resolve } from 'node:path';

const runtime = await startStandaloneRuntime({
import { startCodexRuntime } from './index.js';

const runtime = await startCodexRuntime({
healthPort: readPort('CODEX_GIT_PORT', 0),
projectPath: resolve(process.env.CODEX_GIT_PROJECT_PATH ?? process.cwd()),
surfacePort: readPort('CODEX_GIT_SURFACE_PORT', 5173),
});

console.log(`Codex Git placeholder surface: ${runtime.surfaceUrl.href}`);
console.log(`Codex Git health endpoint: ${runtime.healthUrl.href}`);
console.log(`Codex Git host: ${runtime.currentHost()}`);

let stopping = false;

Expand Down
19 changes: 9 additions & 10 deletions docs/host-integration/codex-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@ official Codex extension interface. The standalone Host Adapter remains the
supported fallback whenever discovery, compatibility, attachment, or remounting
cannot be proven safe.

This foundation implements the typed Host Adapter contract, strict compatibility
probe, DOM lifecycle, message boundary, and CSP lease primitive. Production CDP
discovery and transport, dedicated-instance ownership binding, renderer and DOM
replacement, launcher composition, fallback transitions, and manual smoke
verification are tracked in [#30](https://github.com/codeacme17/codex-git/issues/30).
The production integration launches a dedicated profile, binds its loopback CDP
endpoint and target, and connects only the tested public DOM anchors. It preserves
the launcher-owned project path across renderer generations, reference-counts CSP
bypass, and reports typed standalone transitions when safe attachment is lost.

## Trust and ownership requirements

Expand Down Expand Up @@ -48,17 +47,17 @@ Any Codex version or DOM shape not listed here fails closed before mutation. A
new version requires a new explicit profile and the same fixture and manual smoke
matrix; do not widen selectors to make an unknown build appear compatible.

## Pending manual smoke matrix
## Manual smoke matrix

Issue [#30](https://github.com/codeacme17/codex-git/issues/30) must run this
matrix against a disposable dedicated profile with a loopback CDP endpoint and
record the exact Codex and Chromium versions with the result.
This matrix passed on 2026-08-29 against a disposable dedicated profile using
Codex Desktop `26.820.60940` (build `7119`) and Chromium `151.0.7922.170`.

- Open `Git` and confirm exactly one entry and one full-page frame.
- Select a native destination and confirm native content is restored with no
hidden overlay.
- Open `Git` again after a renderer reload and confirm one new frame generation.
- Change Current Project, theme, and task and confirm typed context updates.
- Change theme/task and confirm typed context updates; change Current Project
and confirm a typed standalone-required transition.
- Send missing, altered, replayed, and stale capability/challenge messages and
confirm they cause no action.
- Close the connection and confirm all nodes, listeners, CDP sessions, and the
Expand Down
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 51 additions & 0 deletions packages/host-adapter/codex-cdp/src/async-stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
interface Consumer<T> {
readonly queue: T[];
wake: (() => void) | null;
}

export class AsyncStream<T> {
private closed = false;
private readonly consumers = new Set<Consumer<T>>();

publish(value: T): void {
if (this.closed) return;
for (const consumer of this.consumers) {
consumer.queue.push(value);
consumer.wake?.();
}
}

close(): void {
this.closed = true;
for (const consumer of this.consumers) {
consumer.wake?.();
}
this.consumers.clear();
}

async *read(initial?: T): AsyncIterable<T> {
const consumer: Consumer<T> = {
queue: initial === undefined ? [] : [initial],
wake: null,
};
if (!this.closed) {
this.consumers.add(consumer);
}

try {
while (!this.closed || consumer.queue.length > 0) {
const value = consumer.queue.shift();
if (value !== undefined) {
yield value;
continue;
}
await new Promise<void>((resolve) => {
consumer.wake = resolve;
});
consumer.wake = null;
}
} finally {
this.consumers.delete(consumer);
}
}
}
118 changes: 118 additions & 0 deletions packages/host-adapter/codex-cdp/src/cdp-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
export interface CdpEvent {
readonly method: string;
readonly params?: unknown;
}

export interface CdpSession {
send(method: string, params?: unknown): Promise<unknown>;
subscribe(listener: (event: CdpEvent) => void): () => void;
close(): Promise<void>;
}

export async function connectCdpSession(url: string): Promise<CdpSession> {
const socket = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
socket.addEventListener('open', () => resolve(), { once: true });
socket.addEventListener(
'error',
() => reject(new Error('Dedicated Codex CDP websocket failed to open')),
{ once: true },
);
});
return new WebSocketCdpSession(socket);
}

class WebSocketCdpSession implements CdpSession {
private nextId = 0;
private readonly listeners = new Set<(event: CdpEvent) => void>();
private readonly pending = new Map<
number,
{ reject(error: Error): void; resolve(value: unknown): void }
>();

constructor(private readonly socket: WebSocket) {
socket.addEventListener('message', this.handleMessage);
socket.addEventListener('close', this.handleClose);
}

send(method: string, params?: unknown): Promise<unknown> {
if (this.socket.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error('Dedicated Codex CDP session is closed'));
}
const id = ++this.nextId;
return new Promise((resolve, reject) => {
this.pending.set(id, { reject, resolve });
this.socket.send(
JSON.stringify(
params === undefined ? { id, method } : { id, method, params },
),
);
});
}

subscribe(listener: (event: CdpEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}

async close(): Promise<void> {
if (this.socket.readyState === WebSocket.CLOSED) {
return;
}
const closed = new Promise<void>((resolve) => {
this.socket.addEventListener('close', () => resolve(), { once: true });
});
this.socket.close();
await Promise.race([
closed,
new Promise<void>((resolve) => setTimeout(resolve, 1_000)),
]);
}

private readonly handleMessage = (event: MessageEvent) => {
if (typeof event.data !== 'string') {
return;
}
let message: unknown;
try {
message = JSON.parse(event.data) as unknown;
} catch {
return;
}
if (!isRecord(message)) {
return;
}
if (typeof message.id === 'number') {
const pending = this.pending.get(message.id);
if (pending === undefined) {
return;
}
this.pending.delete(message.id);
if (isRecord(message.error)) {
pending.reject(new Error('Dedicated Codex CDP command failed'));
} else {
pending.resolve(message.result);
}
return;
}
if (typeof message.method === 'string') {
this.listeners.forEach((listener) =>
listener({ method: message.method as string, params: message.params }),
);
}
};

private readonly handleClose = () => {
const error = new Error('Dedicated Codex CDP session closed');
this.pending.forEach(({ reject }) => reject(error));
this.pending.clear();
this.listeners.forEach((listener) =>
listener({ method: 'CodexGit.sessionClosed' }),
);
this.listeners.clear();
};
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
2 changes: 2 additions & 0 deletions packages/host-adapter/codex-cdp/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ export class CodexHostConnection implements HostConnection {
}
}

async *transitions(): AsyncIterable<never> {}

async perform(action: NativeHostAction): Promise<NativeActionResult> {
if (this.closed) {
return { status: 'rejected' };
Expand Down
Loading