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
46 changes: 42 additions & 4 deletions package-lock.json

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

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
],
"scripts": {
"clean": "npm run clean --workspaces",
"build": "cd packages/harness-state && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../harness-trace && npm run build && cd ../evolution && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build",
"build:offline": "cd packages/harness-state && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../harness-trace && npm run build && cd ../evolution && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build",
"build": "cd packages/harness-state && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../harness-trace && npm run build && cd ../evolution && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build && cd ../coding-agent-server && npm run build",
"build:offline": "cd packages/harness-state && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../harness-trace && npm run build && cd ../evolution && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build && cd ../coding-agent-server && npm run build",
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke",
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
Expand Down Expand Up @@ -65,6 +65,7 @@
},
"version": "0.0.3",
"overrides": {
"nanoid": "3.3.18",
"protobufjs": "7.6.5",
"rimraf": "6.1.2",
"gaxios": {
Expand Down
4 changes: 4 additions & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"types": "./dist/unix.d.ts",
"import": "./dist/unix.js"
},
"./websocket": {
"types": "./dist/websocket.d.ts",
"import": "./dist/websocket.js"
},
"./package.json": "./package.json"
},
"sideEffects": false,
Expand Down
183 changes: 183 additions & 0 deletions packages/client/src/websocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { DEFAULT_MAX_FRAME_LENGTH } from "@earendil-works/diffex-protocol";
import type { ByteTransport, ByteTransportFactory, ByteTransportHandlers } from "./transport.ts";

type WebSocketData = ArrayBuffer | ArrayBufferView;

interface WebSocketMessageEvent {
readonly data: unknown;
}

interface WebSocketCloseEvent {
readonly code: number;
readonly reason: string;
}

interface WebSocketErrorEvent {
readonly error?: unknown;
}

interface WebSocketLike {
binaryType: string;
readonly bufferedAmount: number;
readonly readyState: number;
send(data: WebSocketData): void;
close(code?: number, reason?: string): void;
addEventListener(type: "open", listener: () => void, options?: { once?: boolean }): void;
addEventListener(type: "message", listener: (event: WebSocketMessageEvent) => void): void;
addEventListener(type: "close", listener: (event: WebSocketCloseEvent) => void, options?: { once?: boolean }): void;
addEventListener(type: "error", listener: (event: WebSocketErrorEvent) => void, options?: { once?: boolean }): void;
}

export interface WebSocketConstructor {
new (url: string | URL, protocols?: string | string[]): WebSocketLike;
readonly OPEN: number;
}

export interface WebSocketTransportOptions {
url: string | URL | (() => string | URL | Promise<string | URL>);
protocols?: string | string[];
maxPendingBytes?: number;
webSocket?: WebSocketConstructor;
}

/** Creates authenticated browser-compatible WebSocket transports for DiffexClient connection attempts. */
export function createWebSocketTransportFactory(options: WebSocketTransportOptions): ByteTransportFactory {
const maxPendingBytes = options.maxPendingBytes ?? DEFAULT_MAX_FRAME_LENGTH * 4;
if (!Number.isSafeInteger(maxPendingBytes) || maxPendingBytes <= 0) {
throw new TypeError("WebSocket transport maxPendingBytes must be a positive safe integer");
}

return async (handlers) => {
const url = typeof options.url === "function" ? await options.url() : options.url;
const WebSocketImpl = options.webSocket ?? resolveGlobalWebSocket();
return connectWebSocket(WebSocketImpl, url, options.protocols, maxPendingBytes, handlers);
};
}

function resolveGlobalWebSocket(): WebSocketConstructor {
const candidate = (globalThis as { WebSocket?: WebSocketConstructor }).WebSocket;
if (!candidate) throw new Error("WebSocket is not available in this runtime");
return candidate;
}

function connectWebSocket(
WebSocketImpl: WebSocketConstructor,
url: string | URL,
protocols: string | string[] | undefined,
maxPendingBytes: number,
handlers: ByteTransportHandlers,
): Promise<ByteTransport> {
return new Promise<ByteTransport>((resolve, reject) => {
const socket = protocols === undefined ? new WebSocketImpl(url) : new WebSocketImpl(url, protocols);
socket.binaryType = "arraybuffer";
let connected = false;
let terminal = false;

socket.addEventListener(
"open",
() => {
if (terminal) return;
connected = true;
resolve(
new WebSocketByteTransport(socket, WebSocketImpl.OPEN, maxPendingBytes, () => {
terminal = true;
}),
);
},
{ once: true },
);
socket.addEventListener("message", (event) => {
if (terminal) return;
const bytes = binaryMessage(event.data);
if (!bytes) {
terminal = true;
socket.close(1003, "Binary protocol frames required");
handlers.onError(new Error("WebSocket transport received a non-binary message"));
return;
}
handlers.onData(bytes);
});
socket.addEventListener(
"close",
(event) => {
if (terminal) return;
terminal = true;
if (connected) handlers.onClose();
else reject(new Error(`WebSocket transport closed before connecting (${event.code}: ${event.reason})`));
},
{ once: true },
);
socket.addEventListener(
"error",
(event) => {
if (terminal) return;
terminal = true;
const error = event.error instanceof Error ? event.error : new Error("WebSocket transport failed");
socket.close();
if (connected) handlers.onError(error);
else reject(error);
},
{ once: true },
);
});
}

function binaryMessage(data: unknown): Uint8Array | undefined {
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
return undefined;
}

class WebSocketByteTransport implements ByteTransport {
readonly #socket: WebSocketLike;
readonly #openState: number;
readonly #maxPendingBytes: number;
readonly #markLocalClose: () => void;
#closed = false;
#pendingBytes = 0;
#sendTail: Promise<void> = Promise.resolve();

constructor(socket: WebSocketLike, openState: number, maxPendingBytes: number, markLocalClose: () => void) {
this.#socket = socket;
this.#openState = openState;
this.#maxPendingBytes = maxPendingBytes;
this.#markLocalClose = markLocalClose;
}

send(chunk: Uint8Array): Promise<void> {
if (!(chunk instanceof Uint8Array)) {
return Promise.reject(new TypeError("WebSocket transport chunks must be Uint8Array"));
}
if (this.#closed || this.#socket.readyState !== this.#openState) {
return Promise.reject(new Error("WebSocket transport is closed"));
}
if (this.#pendingBytes + chunk.byteLength > this.#maxPendingBytes) {
return Promise.reject(new Error("WebSocket transport exceeded its pending byte limit"));
}

const bytes = chunk.slice();
this.#pendingBytes += bytes.byteLength;
const send = this.#sendTail.then(() => {
if (this.#closed || this.#socket.readyState !== this.#openState) {
throw new Error("WebSocket transport is closed");
}
this.#socket.send(bytes);
if (this.#socket.bufferedAmount > this.#maxPendingBytes) {
this.close();
throw new Error("WebSocket transport exceeded its buffered byte limit");
}
});
const tracked = send.finally(() => {
this.#pendingBytes -= bytes.byteLength;
});
this.#sendTail = tracked.catch(() => {});
return tracked;
}

close(): void {
if (this.#closed) return;
this.#closed = true;
this.#markLocalClose();
this.#socket.close(1000, "Client closed");
}
}
18 changes: 18 additions & 0 deletions packages/coding-agent-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# @earendil-works/diffex-coding-agent-server

Production service adapter between the Pi-derived Diffex coding-agent runtime and `@earendil-works/diffex-server`.

The hosted control plane allocates one cloud session per sandbox and passes its stable session ID to this service.
Protocol clients attach to that existing session rather than creating an unrelated runtime identity.

This package owns coding-agent snapshot projection and runtime lifecycle integration.
It does not own WorkOS authentication, billing, cloud policy, sandbox allocation, or public attachment credentials.

## Runtime process

The `diffex-runtime-server` executable starts the service with an authenticated WebSocket listener.
The cloud bootstrap supplies `DIFFEX_SESSION_ID`, `DIFFEX_WORKSPACE`, and an internal `DIFFEX_RUNTIME_SHARED_SECRET` of at least 32 bytes.
It also supplies `DIFFEX_MODEL_PROVIDER`, `DIFFEX_MODEL_ID`, and `DIFFEX_MODEL_API_KEY` through the sandbox provider's secret-injection mechanism.
The environment value is an opaque Daytona placeholder, the real provider key never enters the sandbox, and the placeholder is removed from the runtime process environment after the in-memory model client is configured.
Completed model usage is written to a durable sandbox outbox before it is reported to `DIFFEX_USAGE_ENDPOINT`.
Public WorkOS tokens and cloud attachment tickets are consumed by the control plane and are never passed to this process.
31 changes: 31 additions & 0 deletions packages/coding-agent-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@earendil-works/diffex-coding-agent-server",
"version": "0.2.0",
"description": "Hosted Diffex protocol service backed by the Pi-derived coding-agent runtime",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": { "diffex-runtime-server": "./dist/cli.js" },
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist", "README.md"],
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js",
"test": "vitest --run",
"typecheck": "tsgo -p tsconfig.test.json",
"prepublishOnly": "npm run clean && npm run build"
},
"engines": { "node": ">=22.19.0" },
"license": "Apache-2.0",
"dependencies": {
"@earendil-works/diffex-coding-agent": "^0.2.0",
"@earendil-works/diffex-protocol": "^0.2.0",
"@earendil-works/diffex-server": "^0.2.0"
},
"devDependencies": {
"@earendil-works/diffex-client": "^0.2.0",
"shx": "0.4.0",
"vitest": "4.1.9",
"ws": "8.21.0"
}
}
Loading
Loading