Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable user-visible changes are documented here. This project follows

## [Unreleased]

### Added

- Added an All computers Inbox for accounts with multiple linked
computers, with every thread action routed to its source computer.

### Changed

- Made the repository root a directly installable Omarchy marketplace plugin,
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ repository.
- T3 Connect native Clerk browser authentication with the official Relay JWT
template, a secret-authenticated loopback callback handoff, automatic panel
return, restart persistence, and logout.
- T3 Connect environment discovery, selection, remembered preference, and
reconnect with bounded exponential backoff.
- T3 Connect computer discovery, selection, remembered preference, a unified
All computers Inbox when multiple computers are linked, and reconnect with
bounded exponential backoff.
- Nightly Inbox semantics for pinned, active, snoozed, and settled threads,
including Working, Ready, Input, Approval, and failure attention state.
- Chat-first streamed thread detail with Markdown, per-turn changed-file trees,
Expand Down
121 changes: 104 additions & 17 deletions bridge/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ import { NativeClerkProvider } from "./auth/nativeProvider.ts";
import { ConnectionCoordinator } from "./connection/coordinator.ts";
import { NdjsonChannel, type NdjsonHandler } from "./ipc/ndjson.ts";
import { event, failure, success } from "./protocol/output.ts";
import { PROTOCOL_VERSION, type BridgeRequest } from "./protocol/types.ts";
import {
ALL_COMPUTERS_ENVIRONMENT_ID,
PROTOCOL_VERSION,
type BridgeRequest,
type InboxDto,
} from "./protocol/types.ts";
import { asBridgeError, BridgeError } from "./security/redact.ts";
import { MemorySecretStore, SecretServiceStore } from "./security/secretStore.ts";
import { AllComputersInbox } from "./t3/allComputers.ts";
import { T3Commands } from "./t3/commands.ts";
import { DpopKeyManager } from "./t3/dpop.ts";
import { T3RelayClient } from "./t3/relay.ts";
Expand All @@ -20,7 +26,9 @@ export class BridgeApp implements NdjsonHandler {
readonly channel: NdjsonChannel;
private readonly auth: NativeClerkProvider;
private readonly connection: ConnectionCoordinator;
private readonly allComputers: AllComputersInbox;
private readonly commands: T3Commands;
private openThreadSession: T3EnvironmentSession | null = null;
private login: Promise<void> | null = null;
private shuttingDown = false;

Expand Down Expand Up @@ -50,23 +58,58 @@ export class BridgeApp implements NdjsonHandler {
process.env.T3CODE_RELAY_URL || "https://relay.t3.codes",
);
let coordinator!: ConnectionCoordinator;
const session = new T3EnvironmentSession({
const session = this.createSession(
(inbox) => {
if (this.allComputers.active) this.allComputers.updatePrimary(inbox);
else this.emit("inbox.changed", inbox);
},
(error) => coordinator.handleClosed(error),
);
coordinator = new ConnectionCoordinator(this.auth, relay, session, {
onConnection: (status) => this.emit("connection.changed", status),
onEnvironment: (payload) => this.emit("environment.changed", payload),
onError: (error) => this.emitError(error),
});
this.connection = coordinator;
this.allComputers = new AllComputersInbox(relay, {
createSession: (_environmentId, onInbox, onClosed) => this.createSession(onInbox, onClosed),
onInbox: (inbox) => this.emit("inbox.changed", inbox),
onError: (error) => this.emitError(error),
});
this.commands = new T3Commands((environmentId) => this.sessionFor(environmentId));
}

private createSession(
onInbox: (inbox: InboxDto) => void,
onClosed: (error: unknown) => void,
): T3EnvironmentSession {
return new T3EnvironmentSession({
onInbox,
onThread: (thread) => this.emit("thread.snapshot", thread),
onMessageDelta: (payload) => this.emit("message.delta", payload),
onMessageCompleted: (payload) => this.emit("message.completed", payload),
onApproval: (payload) => this.emit("approval.requested", payload),
onInput: (payload) => this.emit("input.requested", payload),
onClosed: (error) => coordinator.handleClosed(error),
onClosed,
onError: (error) => this.emitError(error),
});
coordinator = new ConnectionCoordinator(this.auth, relay, session, {
onConnection: (status) => this.emit("connection.changed", status),
onEnvironment: (payload) => this.emit("environment.changed", payload),
onError: (error) => this.emitError(error),
});
this.connection = coordinator;
this.commands = new T3Commands(session);
}

private primaryInbox(): InboxDto | null {
const environmentId = this.connection.selectedId();
if (environmentId === null) return null;
try {
return this.connection.session.projection.inbox(environmentId);
} catch {
return null;
}
}

private sessionFor(environmentId?: string): T3EnvironmentSession {
const primaryEnvironmentId = this.connection.selectedId();
if (!environmentId || environmentId === primaryEnvironmentId) return this.connection.session;
if (this.allComputers.active) return this.allComputers.sessionFor(environmentId, this.connection.session);
throw new BridgeError("ENVIRONMENT_NOT_SELECTED", "Select that computer before changing its thread.");
}

private emit(name: string, payload: unknown): void {
Expand All @@ -88,7 +131,12 @@ export class BridgeApp implements NdjsonHandler {
async start(): Promise<void> {
this.channel.start();
const auth = await this.auth.initialize();
this.emit("bridge.ready", { protocolVersion: PROTOCOL_VERSION, bridgeVersion: packageMetadata.version, upstream: UPSTREAM });
this.emit("bridge.ready", {
protocolVersion: PROTOCOL_VERSION,
bridgeVersion: packageMetadata.version,
upstream: UPSTREAM,
allComputersEnvironmentId: ALL_COMPUTERS_ENVIRONMENT_ID,
});
this.emit("connection.changed", this.connection.status());
if (auth.phase === "signedIn") {
void this.connection.discoverAndConnectPreferred().catch((error) => this.emitUnexpectedConnectionError(error));
Expand All @@ -115,7 +163,12 @@ export class BridgeApp implements NdjsonHandler {
let payload: unknown;
switch (request.type) {
case "bridge.ping":
payload = { ready: true, protocolVersion: PROTOCOL_VERSION, upstream: UPSTREAM };
payload = {
ready: true,
protocolVersion: PROTOCOL_VERSION,
upstream: UPSTREAM,
allComputersEnvironmentId: ALL_COMPUTERS_ENVIRONMENT_ID,
};
break;
case "bridge.shutdown":
payload = { shuttingDown: true };
Expand All @@ -130,26 +183,59 @@ export class BridgeApp implements NdjsonHandler {
payload = { started: true };
break;
case "auth.logout":
await this.allComputers.deactivate().catch(() => undefined);
await this.connection.disconnect();
payload = await this.auth.logout();
break;
case "environment.list":
payload = { environments: await this.connection.discover(), selected: this.connection.selectedId() };
break;
case "environment.select":
await this.connection.select(String(request.payload.environmentId));
payload = { selected: this.connection.selectedId() };
if (String(request.payload.environmentId) === ALL_COMPUTERS_ENVIRONMENT_ID) {
payload = {
selected: ALL_COMPUTERS_ENVIRONMENT_ID,
inbox: await this.allComputers.activate(
this.connection.list(),
this.connection.selectedId(),
this.primaryInbox(),
),
};
} else {
await this.allComputers.deactivate();
await this.connection.select(String(request.payload.environmentId));
const inbox = this.primaryInbox();
if (inbox !== null) this.emit("inbox.changed", inbox);
payload = { selected: this.connection.selectedId(), ...(inbox === null ? {} : { inbox }) };
}
break;
case "inbox.get":
if (this.allComputers.active) {
payload = this.allComputers.current();
break;
}
if (this.connection.selectedId() === null) throw new BridgeError("ENVIRONMENT_REQUIRED", "Choose a T3 environment first.");
payload = this.connection.session.projection.inbox(this.connection.selectedId()!);
break;
case "thread.open":
await this.connection.session.openThread(String(request.payload.threadId));
payload = { opening: request.payload.threadId };
{
const session = this.sessionFor(
typeof request.payload.environmentId === "string" ? request.payload.environmentId : undefined,
);
if (this.openThreadSession !== null && this.openThreadSession !== session) {
await this.openThreadSession.closeThread();
}
this.openThreadSession = null;
await session.openThread(String(request.payload.threadId));
this.openThreadSession = session;
payload = {
opening: request.payload.threadId,
models: session.projection.models(),
};
}
break;
case "thread.close":
await this.connection.session.closeThread();
if (this.openThreadSession !== null) await this.openThreadSession.closeThread();
this.openThreadSession = null;
payload = {};
break;
default:
Expand All @@ -168,6 +254,7 @@ export class BridgeApp implements NdjsonHandler {
if (this.shuttingDown) return;
this.shuttingDown = true;
this.commands.clearAttachments();
await this.allComputers.deactivate().catch(() => undefined);
await this.connection.disconnect().catch(() => undefined);
this.channel.stop();
}
Expand Down
8 changes: 8 additions & 0 deletions bridge/src/protocol/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ function optionalAttachmentIds(payload: Record<string, unknown>): string[] {
}

function validatePayload(type: RequestType, payload: Record<string, unknown>): void {
if (
type.startsWith("thread.") ||
type.startsWith("attachment.") ||
type === "approval.respond" ||
type === "input.respond"
) {
if (payload.environmentId !== undefined) requiredString(payload, "environmentId", 256);
}
switch (type) {
case "attachment.clipboard.read":
requiredString(payload, "threadId", 256);
Expand Down
2 changes: 2 additions & 0 deletions bridge/src/protocol/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const PROTOCOL_VERSION = 1 as const;
export const ALL_COMPUTERS_ENVIRONMENT_ID = "__all_computers__" as const;

export type AuthPhase = "signedOut" | "signingIn" | "signedIn" | "error";
export interface AuthStatusDto {
Expand Down Expand Up @@ -68,6 +69,7 @@ export type ThreadPhase =
export type InboxSection = "pinned" | "active" | "snoozed" | "settled";

export interface ThreadSummaryDto {
environmentId: string;
id: string;
projectId: string;
project: string;
Expand Down
Loading