Gateway: T3 RPC client, host connection, awareness port, storage, API contract

This commit is contained in:
gib committed 2026-11-01 02:04:16 -05:00
1 parent 0fd556e313
commit 972f6e4be5
13 files changed
+1332 -9

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
// What a thread is doing, from the watch's point of view. Ported from T3 Code's
// `packages/shared/src/agentAwareness.ts` (MIT), which drives T3's own mobile notifications.
import type { ThreadShell } from "./t3/types.ts";
export type Phase =
| "starting"
| "running"
| "waiting_for_approval"
| "waiting_for_input"
| "completed"
| "failed";
type AwarenessFields = Pick<
ThreadShell,
| "activityRunStatus"
| "lineage"
| "latestRunStopRequested"
| "pendingBackgroundTasks"
| "pendingRuntimeRequest"
| "settledOverride"
| "status"
>;
/**
* The phase to show for a thread, or null when the watch should not show it: subagent children,
* threads that never ran or were stopped, and finished work the user already settled.
*/
export function threadPhase(thread: AwarenessFields): Phase | null {
if (thread.lineage.relationshipToParent === "subagent") return null;
const phase = resolvePhase(thread);
if (phase === null) return null;
if (phase === "completed" && thread.latestRunStopRequested === true) return null;
if (
thread.settledOverride === "settled" &&
(phase === "completed" || phase === "failed") &&
!backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])
) {
return null;
}
return phase;
}
function resolvePhase(thread: AwarenessFields): Phase | null {
const runStatus = thread.activityRunStatus ?? thread.status;
const request = thread.pendingRuntimeRequest;
if (
request?.kind === "user_input" &&
(request.blocking !== false || !(runStatus === "preparing" || runStatus === "starting" || runStatus === "running"))
) {
return "waiting_for_input";
}
if (request && request.kind !== "user_input" && request.kind !== "auth_refresh") {
return "waiting_for_approval";
}
switch (runStatus) {
case "preparing":
case "starting":
return "starting";
case "running":
case "waiting":
return "running";
case "completed":
return backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) ? "running" : "completed";
case "failed":
return "failed";
default:
return null;
}
}
// Commands and monitors are as often a dev server or its watchdog as a wait, so they hold
// completion only when the agent marks them awaited.
function backgroundWorkHoldsCompletion(tasks: NonNullable<ThreadShell["pendingBackgroundTasks"]>): boolean {
return tasks.some((task) => task.awaited === true || task.kind === "subagent" || task.kind === "background_task");
}
+134
View File
@@ -0,0 +1,134 @@
// The JSON the watch app reads and sends. This is the contract between the gateway and the
// watch's Codable models (watch/WristCode/API/Models.swift). docs/api.md describes it for humans.
// Change both sides together.
import type { Phase } from "./awareness.ts";
import type { ApprovalDecision } from "./t3/types.ts";
import type { HostStatus } from "./t3/host.ts";
export interface HostSummary {
id: string;
label: string;
status: HostStatus;
error: string | null;
}
/** Something a thread is waiting on: an approval, or questions for the user. */
export interface PendingRequest {
id: string;
/** `command`, `file-change`, `permission`, `user_input`, ... */
kind: string;
/** What the agent wants to do, such as the command line. Null when the server gave none. */
prompt: string | null;
reason: string | null;
/** Approval choices, in the server's order. Empty for questions. */
options: Array<{ decision: ApprovalDecision; label: string }>;
/** Questions for the user. Empty for approvals. */
questions: Array<{
id: string;
header: string;
question: string;
options: Array<{ label: string; value: string }>;
allowCustomAnswer: boolean;
}>;
}
export interface FeedItem {
hostId: string;
hostLabel: string;
threadId: string;
projectId: string;
projectTitle: string;
title: string;
phase: Phase;
updatedAt: string;
/** The agent's latest message, trimmed for the watch. Null until the gateway has read it. */
lastMessage: string | null;
request: PendingRequest | null;
}
export interface Feed {
/** Waiting for approval or input. Oldest request first. */
needsYou: FeedItem[];
/** Starting or running. Most recently updated first. */
working: FeedItem[];
/** Completed or failed in the last 24 hours. Most recent first, at most 10. */
recent: FeedItem[];
hosts: HostSummary[];
}
export interface Message {
id: string;
role: "user" | "assistant" | "system";
text: string;
createdAt: string;
}
export interface ThreadDetail {
item: FeedItem;
/** Oldest first, at most 20. */
messages: Message[];
/** True while a run is active, so Stop makes sense. */
canStop: boolean;
}
export interface ProjectsResponse {
hosts: Array<
HostSummary & {
projects: Array<{
id: string;
title: string;
/** The model a new thread will use, such as `claude-opus-5-5`. Null when none is configured. */
model: string | null;
}>;
}
>;
}
export interface PairRequest {
code: string;
deviceName: string;
}
export interface PairResponse {
deviceId: string;
deviceToken: string;
}
export interface PushRegistration {
apnsToken: string;
apnsEnvironment: "sandbox" | "production";
}
export interface SendMessageRequest {
text: string;
}
export type RespondRequest =
| { requestId: string; decision: ApprovalDecision }
| { requestId: string; answers: Record<string, string> };
export interface NewThreadRequest {
hostId: string;
projectId: string;
text: string;
}
export interface NewThreadResponse {
hostId: string;
threadId: string;
}
export interface ErrorResponse {
error: string;
}
/** Custom keys on every push notification, beside `aps`. */
export interface PushPayload {
hostId: string;
threadId: string;
requestId?: string;
}
/** Notification categories the watch registers actions for. */
export type PushCategory = "approval" | "finished" | "failed" | "input" | "host";
+140
View File
@@ -0,0 +1,140 @@
// SQLite state: the T3 hosts the gateway connects to, the watches paired with it, and pending
// pairing codes. The CLI and the server open the same file, and the server rereads `hosts` so a
// `wristcode host add` takes effect without a restart.
import { Database } from "bun:sqlite";
export interface HostRow {
id: string;
label: string;
url: string;
environment_id: string;
token: string;
token_expires_at: string | null;
created_at: string;
}
export interface DeviceRow {
id: string;
name: string;
token_hash: string;
apns_token: string | null;
apns_env: "sandbox" | "production" | null;
created_at: string;
last_seen_at: string | null;
revoked_at: string | null;
}
const SCHEMA = `
create table if not exists hosts (
id text primary key,
label text not null,
url text not null unique,
environment_id text not null unique,
token text not null,
token_expires_at text,
created_at text not null
);
create table if not exists devices (
id text primary key,
name text not null,
token_hash text not null unique,
apns_token text,
apns_env text,
created_at text not null,
last_seen_at text,
revoked_at text
);
create table if not exists pairing_codes (
code_hash text primary key,
expires_at text not null,
used_at text
);
`;
export const sha256 = (value: string) => new Bun.CryptoHasher("sha256").update(value).digest("hex");
const now = () => new Date().toISOString();
export function openDb(path: string) {
const db = new Database(path, { create: true, strict: true });
db.run("pragma journal_mode = wal");
db.run("pragma busy_timeout = 5000");
db.run(SCHEMA);
return {
hosts: {
list: () => db.query<HostRow, []>("select * from hosts order by created_at").all(),
upsert(host: Omit<HostRow, "created_at">) {
db.query(
`insert into hosts (id, label, url, environment_id, token, token_expires_at, created_at)
values ($id, $label, $url, $environment_id, $token, $token_expires_at, $created_at)
on conflict(environment_id) do update set
label = excluded.label, url = excluded.url, token = excluded.token,
token_expires_at = excluded.token_expires_at`,
).run({ ...host, created_at: now() });
},
remove: (idOrLabel: string) =>
db.query("delete from hosts where id = $v or label = $v").run({ v: idOrLabel }).changes > 0,
},
devices: {
list: () => db.query<DeviceRow, []>("select * from devices order by created_at").all(),
active: () => db.query<DeviceRow, []>("select * from devices where revoked_at is null").all(),
create(name: string, token: string): string {
const id = crypto.randomUUID();
db.query(
"insert into devices (id, name, token_hash, created_at) values ($id, $name, $hash, $created_at)",
).run({ id, name, hash: sha256(token), created_at: now() });
return id;
},
/** The active device that owns this bearer token, marking it seen. */
authenticate(token: string): DeviceRow | null {
const device = db
.query<DeviceRow, { hash: string }>("select * from devices where token_hash = $hash and revoked_at is null")
.get({ hash: sha256(token) });
if (device) db.query("update devices set last_seen_at = $at where id = $id").run({ at: now(), id: device.id });
return device;
},
setPush(id: string, apnsToken: string, apnsEnv: "sandbox" | "production") {
db.query("update devices set apns_token = $apnsToken, apns_env = $apnsEnv where id = $id").run({
id,
apnsToken,
apnsEnv,
});
},
clearPush: (apnsToken: string) =>
db.query("update devices set apns_token = null where apns_token = $apnsToken").run({ apnsToken }),
revoke: (idOrName: string) =>
db.query("update devices set revoked_at = $at where (id = $v or name = $v) and revoked_at is null").run({
at: now(),
v: idOrName,
}).changes,
},
pairing: {
create(code: string, ttlMs: number) {
db.query("insert into pairing_codes (code_hash, expires_at) values ($hash, $expires_at)").run({
hash: sha256(code),
expires_at: new Date(Date.now() + ttlMs).toISOString(),
});
},
/** Marks the code used and returns true if it was valid. */
redeem(code: string): boolean {
return (
db
.query(
"update pairing_codes set used_at = $at where code_hash = $hash and used_at is null and expires_at > $at",
)
.run({ hash: sha256(code), at: now() }).changes > 0
);
},
/** Voids every open code, after too many wrong guesses. */
voidAll: () => db.query("update pairing_codes set used_at = $at where used_at is null").run({ at: now() }),
},
close: () => db.close(),
};
}
export type Db = ReturnType<typeof openDb>;
+267
View File
@@ -0,0 +1,267 @@
// One T3 Code server. Keeps a shell stream open, holds every project and thread shell in memory,
// and reconnects with backoff. Writes go over the same socket, and thread pages go over HTTP.
import { RpcError, RpcSocket } from "./rpc.ts";
import {
ORCHESTRATION_PROTOCOL_VERSION,
type BoundedThread,
type Command,
type EnvironmentDescriptor,
type LaunchThreadInput,
type Project,
type ServerConfig,
type ShellStreamItem,
type ThreadShell,
} from "./types.ts";
export type HostStatus =
| "connecting"
| "online"
| "offline"
/** The token expired or was revoked. A new one has to be paired in. */
| "auth_invalid"
/** The server speaks another protocol version or is a different environment. */
| "incompatible";
export interface HostConfig {
id: string;
label: string;
/** The server's HTTP origin, such as `https://t3mac.gbrown.org`. */
url: string;
token: string;
environmentId: string;
}
export interface ThreadChange {
host: T3Host;
previous: ThreadShell | undefined;
next: ThreadShell | undefined;
/** True while loading the first snapshot after start, when nothing should notify. */
initial: boolean;
}
export interface HostOptions {
onThreadChange?: (change: ThreadChange) => void;
onStatus?: (host: T3Host) => void;
log?: (message: string) => void;
}
const BACKOFF_MIN_MS = 1_000;
const BACKOFF_MAX_MS = 5 * 60_000;
const AUTH_RETRY_MS = 10 * 60_000;
export class HostError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "HostError";
}
}
export class T3Host {
status: HostStatus = "connecting";
lastError: string | null = null;
readonly projects = new Map<string, Project>();
readonly threads = new Map<string, ThreadShell>();
serverConfig: ServerConfig | null = null;
private socket: RpcSocket | null = null;
private sequence: number | null = null;
private hasSnapshot = false;
private stopped = false;
private attempt = 0;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
readonly config: HostConfig,
private readonly options: HostOptions = {},
) {}
get id() {
return this.config.id;
}
get label() {
return this.config.label;
}
start() {
this.stopped = false;
void this.connect();
}
stop() {
this.stopped = true;
if (this.retryTimer) clearTimeout(this.retryTimer);
this.socket?.close();
}
/** Sends one orchestration command and resolves with its sequence. */
async dispatch(command: Command): Promise<{ sequence: number }> {
return this.requireSocket().call("orchestration.dispatchCommand", command);
}
async launchThread(input: LaunchThreadInput): Promise<{ threadId: string }> {
return this.requireSocket().call("orchestration.launchThread", input);
}
/** The recent window of one thread: its messages and turn items. */
bounded(threadId: string): Promise<BoundedThread> {
return this.http<BoundedThread>(`/api/orchestration/threads/${encodeURIComponent(threadId)}/bounded`);
}
/** The session's expiry, from `/api/auth/session`. */
async sessionExpiresAt(): Promise<string | null> {
const session = await this.http<{ expiresAt?: string }>("/api/auth/session");
return session.expiresAt ?? null;
}
supports(capability: string): boolean {
return this.serverConfig?.environment?.capabilities?.[capability] === true;
}
private requireSocket(): RpcSocket {
if (this.socket === null || this.status !== "online") {
throw new HostError(`${this.label} is ${this.status}`, 503);
}
return this.socket;
}
private async http<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(new URL(path, this.config.url), {
...init,
headers: {
authorization: `Bearer ${this.config.token}`,
"x-t3-orchestration-protocol": String(ORCHESTRATION_PROTOCOL_VERSION),
...init.headers,
},
});
if (!response.ok) {
const body = await response.text();
throw new HostError(`${this.label} ${path} returned ${response.status}: ${body.slice(0, 200)}`, response.status);
}
return response.json() as Promise<T>;
}
private setStatus(status: HostStatus, error: string | null = null) {
this.status = status;
this.lastError = error;
this.options.onStatus?.(this);
}
private async connect() {
if (this.stopped) return;
this.setStatus("connecting", this.lastError);
try {
const descriptor = await this.http<EnvironmentDescriptor>("/.well-known/t3/environment");
const protocol = descriptor.orchestrationProtocolVersion ?? 1;
if (protocol !== ORCHESTRATION_PROTOCOL_VERSION) {
throw new HostError(`speaks orchestration protocol ${protocol}, the gateway speaks ${ORCHESTRATION_PROTOCOL_VERSION}`, 426);
}
if (descriptor.environmentId !== this.config.environmentId) {
throw new HostError(`is environment ${descriptor.environmentId}, expected ${this.config.environmentId}`, 409);
}
const { ticket } = await this.http<{ ticket: string }>("/api/auth/websocket-ticket/rpc", { method: "POST" });
const wsUrl = new URL("/ws", this.config.url);
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
wsUrl.searchParams.set("wsTicket", ticket);
wsUrl.searchParams.set("orchestrationProtocol", String(ORCHESTRATION_PROTOCOL_VERSION));
const socket = await RpcSocket.connect(wsUrl.href);
this.socket = socket;
this.serverConfig = await socket.call<ServerConfig>("server.getConfig", {});
this.subscribe(socket);
this.attempt = 0;
this.setStatus("online");
await socket.closed;
this.socket = null;
this.scheduleRetry("offline", "connection closed");
} catch (error) {
this.socket?.close();
this.socket = null;
const message = error instanceof Error ? error.message : String(error);
if (error instanceof HostError && error.status === 401) this.scheduleRetry("auth_invalid", message);
else if (error instanceof HostError && (error.status === 426 || error.status === 409)) this.scheduleRetry("incompatible", message);
else this.scheduleRetry("offline", message);
}
}
private subscribe(socket: RpcSocket) {
const input = this.hasSnapshot && this.sequence !== null ? { afterSequence: this.sequence } : {};
const { done } = socket.stream("orchestration.subscribeShell", input, (value) =>
this.apply(value as ShellStreamItem),
);
// The stream only ends when the socket does, or on a server error. Either way, start over.
done.then(
() => socket.close(),
(error: RpcError) => {
this.options.log?.(`${this.label}: shell stream ended: ${error.message}`);
socket.close();
},
);
}
private scheduleRetry(status: HostStatus, error: string) {
if (this.stopped) return;
this.setStatus(status, error);
this.options.log?.(`${this.label}: ${status}: ${error}`);
const delay =
status === "auth_invalid" || status === "incompatible"
? AUTH_RETRY_MS
: Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** this.attempt++) * (0.5 + Math.random() / 2);
this.retryTimer = setTimeout(() => void this.connect(), delay);
}
/** Applies one shell stream item to the in-memory state, reporting each thread that changed. */
apply(item: ShellStreamItem) {
switch (item.kind) {
case "snapshot": {
const { snapshot } = item;
for (const project of snapshot.projects) this.projects.set(project.id, project);
const known = new Set(snapshot.projects.map((project) => project.id));
for (const id of this.projects.keys()) if (!known.has(id)) this.projects.delete(id);
this.sequence = Math.max(this.sequence ?? 0, snapshot.snapshotSequence);
// The server also sends project-only snapshots with no threads: one first on every resume,
// and more as project metadata fills in. T3's own client keeps its threads for those, and
// so does the gateway.
if (snapshot.threads.length === 0 && this.hasSnapshot) return;
const initial = !this.hasSnapshot;
this.hasSnapshot = true;
const next = new Map(snapshot.threads.map((thread) => [thread.id, thread]));
for (const [id, previous] of this.threads) {
if (!next.has(id)) this.changeThread(previous, undefined, initial);
}
for (const thread of next.values()) this.changeThread(this.threads.get(thread.id), thread, initial);
return;
}
case "project.updated":
this.projects.set(item.project.id, item.project);
this.sequence = item.sequence;
return;
case "project.removed":
this.projects.delete(item.projectId);
this.sequence = item.sequence;
return;
case "thread.updated":
this.sequence = item.sequence;
if (item.location === "active") this.changeThread(this.threads.get(item.thread.id), item.thread, false);
else this.changeThread(this.threads.get(item.thread.id), undefined, false);
return;
case "thread.removed": {
this.sequence = item.sequence;
const previous = this.threads.get(item.threadId);
if (previous) this.changeThread(previous, undefined, false);
return;
}
case "synchronized":
return;
}
}
private changeThread(previous: ThreadShell | undefined, next: ThreadShell | undefined, initial: boolean) {
if (next) this.threads.set(next.id, next);
else if (previous) this.threads.delete(previous.id);
this.options.onThreadChange?.({ host: this, previous, next, initial });
}
}
+183
View File
@@ -0,0 +1,183 @@
// A minimal client for T3 Code's WebSocket RPC, which speaks Effect RPC's JSON framing.
// One socket multiplexes unary calls and streams by request id. Streams are flow controlled: the
// server waits for an Ack after every Chunk, so `stream` acks each one after its handler runs.
// See docs/t3-protocol.md, section 2.
type FromServer =
| { _tag: "Chunk"; requestId: string; values: unknown[] }
| { _tag: "Exit"; requestId: string; exit: Exit }
| { _tag: "Defect"; defect: unknown }
| { _tag: "Pong" }
| { _tag: "ClientProtocolError"; error: unknown };
type Exit =
| { _tag: "Success"; value: unknown }
| { _tag: "Failure"; cause: Array<{ _tag: string; error?: unknown; defect?: unknown }> };
/** A call that ended in a Failure exit, or a socket that closed under it. */
export class RpcError extends Error {
constructor(
message: string,
/** The typed error from the server, such as `{ _tag: "EnvironmentAuthorizationError", ... }`. */
readonly error?: unknown,
) {
super(message);
this.name = "RpcError";
}
}
interface Pending {
onChunk?: (value: unknown) => void;
resolve: (value: unknown) => void;
reject: (error: RpcError) => void;
}
export interface RpcSocketOptions {
pingIntervalMs?: number;
/** Close the socket when the server has sent nothing for this long. */
silenceTimeoutMs?: number;
}
/** One open RPC WebSocket. It never reconnects; the caller makes a new one. */
export class RpcSocket {
private nextId = 1;
private readonly pending = new Map<string, Pending>();
private lastFrameAt = Date.now();
private readonly timer: ReturnType<typeof setInterval>;
/** Resolves when the socket closes, for any reason. */
readonly closed: Promise<void>;
private constructor(
private readonly ws: WebSocket,
options: RpcSocketOptions,
) {
const pingIntervalMs = options.pingIntervalMs ?? 5_000;
const silenceTimeoutMs = options.silenceTimeoutMs ?? 15_000;
this.closed = new Promise((resolve) => ws.addEventListener("close", () => resolve()));
ws.addEventListener("message", (event) => this.onMessage(event.data));
ws.addEventListener("close", () => this.failAll("socket closed"));
this.timer = setInterval(() => {
if (Date.now() - this.lastFrameAt > silenceTimeoutMs) {
this.close();
return;
}
this.send({ _tag: "Ping" });
}, pingIntervalMs);
void this.closed.then(() => clearInterval(this.timer));
}
/** Opens a socket and resolves once it is connected. */
static connect(url: string, options: RpcSocketOptions = {}): Promise<RpcSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const onError = () => reject(new RpcError(`could not connect to ${new URL(url).host}`));
ws.addEventListener("error", onError, { once: true });
ws.addEventListener(
"open",
() => {
ws.removeEventListener("error", onError);
resolve(new RpcSocket(ws, options));
},
{ once: true },
);
});
}
/** Calls a unary method and resolves with its success value. */
call<T = unknown>(tag: string, payload: unknown): Promise<T> {
return new Promise<T>((resolve, reject) => {
const id = this.request(tag, payload, { resolve: resolve as (v: unknown) => void, reject });
if (id === null) reject(new RpcError("socket is not open"));
});
}
/**
* Opens a stream. `onChunk` runs once per value, in order. The promise settles when the stream
* ends or fails. Call `cancel` to interrupt it without closing the socket.
*/
stream(tag: string, payload: unknown, onChunk: (value: unknown) => void) {
let id: string | null = null;
const done = new Promise<void>((resolve, reject) => {
id = this.request(tag, payload, { onChunk, resolve: () => resolve(), reject });
if (id === null) reject(new RpcError("socket is not open"));
});
return {
done,
cancel: () => {
if (id !== null) this.send({ _tag: "Interrupt", requestId: id });
},
};
}
close() {
clearInterval(this.timer);
this.ws.close();
}
private request(tag: string, payload: unknown, pending: Pending): string | null {
if (this.ws.readyState !== WebSocket.OPEN) return null;
const id = String(this.nextId++);
this.pending.set(id, pending);
this.send({ _tag: "Request", id, tag, payload, headers: [] });
return id;
}
private send(frame: object) {
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));
}
private onMessage(data: unknown) {
this.lastFrameAt = Date.now();
if (typeof data !== "string") return;
const parsed: unknown = JSON.parse(data);
const frames = (Array.isArray(parsed) ? parsed : [parsed]) as FromServer[];
for (const frame of frames) this.onFrame(frame);
}
private onFrame(frame: FromServer) {
switch (frame._tag) {
case "Chunk": {
const requestId = String(frame.requestId);
const pending = this.pending.get(requestId);
if (!pending) return;
for (const value of frame.values) pending.onChunk?.(value);
this.send({ _tag: "Ack", requestId: frame.requestId });
return;
}
case "Exit": {
const requestId = String(frame.requestId);
const pending = this.pending.get(requestId);
if (!pending) return;
this.pending.delete(requestId);
if (frame.exit._tag === "Success") pending.resolve(frame.exit.value);
else pending.reject(failureToError(frame.exit.cause));
return;
}
case "Defect":
this.failAll(`server defect: ${JSON.stringify(frame.defect)}`);
this.close();
return;
case "ClientProtocolError":
this.failAll(`protocol error: ${JSON.stringify(frame.error)}`);
this.close();
return;
case "Pong":
return;
}
}
private failAll(message: string) {
for (const pending of this.pending.values()) pending.reject(new RpcError(message));
this.pending.clear();
}
}
function failureToError(cause: Extract<Exit, { _tag: "Failure" }>["cause"]): RpcError {
const first = cause[0];
if (first?.error !== undefined) {
const error = first.error as { _tag?: string; message?: string };
return new RpcError(error.message ?? error._tag ?? "call failed", first.error);
}
if (first?._tag === "Interrupt") return new RpcError("interrupted");
return new RpcError(`call failed: ${JSON.stringify(first?.defect ?? cause)}`);
}
+199
View File
@@ -0,0 +1,199 @@
// Narrow types for the parts of T3 Code's orchestration protocol (v2) the gateway reads and writes.
// They cover only the fields we use. Unknown fields pass through untouched, and every enum leaves
// room for values a newer server may add. Source of truth: docs/t3-protocol.md.
/** A string union that still accepts values a newer server adds. */
type Open<T extends string> = T | (string & {});
export type RunStatus = Open<
| "idle"
| "preparing"
| "queued"
| "starting"
| "running"
| "waiting"
| "completed"
| "interrupted"
| "failed"
| "cancelled"
| "rolled_back"
>;
export type RuntimeRequestKind = Open<
| "command"
| "file-read"
| "file-change"
| "mcp-elicitation"
| "permission"
| "user_input"
| "dynamic_tool_call"
| "auth_refresh"
>;
export type ApprovalDecision = "accept" | "acceptForSession" | "acceptAlways" | "decline" | "cancel";
export interface ModelSelection {
instanceId: string;
model: string;
options?: unknown;
}
export interface Project {
id: string;
title: string;
workspaceRoot: string;
defaultModelSelection: ModelSelection | null;
updatedAt: string;
}
export interface ThreadShell {
id: string;
projectId: string;
title: string;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
deletedAt?: string | null;
status: RunStatus;
activityRunStatus?: RunStatus | null;
activeRunId: string | null;
latestRunStopRequested?: boolean;
pendingRuntimeRequest: {
id: string;
kind: RuntimeRequestKind;
createdAt: string;
blocking?: boolean;
} | null;
pendingBackgroundTasks?: Array<{ kind: Open<"command" | "monitor" | "subagent" | "background_task">; awaited?: boolean }>;
lastError?: string | null;
modelSelection: ModelSelection;
runtimeMode?: string;
lineage: { relationshipToParent: Open<"subagent"> | null };
settledOverride?: Open<"settled"> | null;
}
export interface ShellSnapshot {
snapshotSequence: number;
projects: Project[];
threads: ThreadShell[];
}
export type ShellStreamItem =
| { kind: "snapshot"; snapshot: ShellSnapshot }
| { kind: "project.updated"; sequence: number; project: Project }
| { kind: "project.removed"; sequence: number; projectId: string }
| { kind: "thread.updated"; sequence: number; location: Open<"active" | "archive">; thread: ThreadShell }
| { kind: "thread.removed"; sequence: number; location: Open<"active" | "archive">; threadId: string }
| { kind: "synchronized" };
export interface ConversationMessage {
id: string;
role: Open<"user" | "assistant" | "system">;
text: string;
streaming?: boolean;
createdAt: string;
updatedAt: string;
}
export interface ApprovalOption {
decision: ApprovalDecision;
label: string;
warning?: string;
}
export interface UserInputQuestion {
id: string;
header: string;
question: string;
options: Array<{ label: string; description: string; value?: string }>;
allowCustomAnswer?: boolean;
multiSelect?: boolean;
}
/** Any turn item. Narrow with the guards below to read the two types the gateway uses. */
export interface TurnItem {
type: string;
title?: string | null;
}
export interface ApprovalRequestItem extends TurnItem {
type: "approval_request";
requestId: string;
requestKind: RuntimeRequestKind;
prompt?: string;
reason?: string;
options?: ApprovalOption[];
}
export interface UserInputRequestItem extends TurnItem {
type: "user_input_request";
requestId: string;
questions: UserInputQuestion[];
}
export const isApprovalRequest = (item: TurnItem): item is ApprovalRequestItem =>
item.type === "approval_request";
export const isUserInputRequest = (item: TurnItem): item is UserInputRequestItem =>
item.type === "user_input_request";
export interface BoundedThread {
snapshotSequence: number;
projection: {
thread: ThreadShell;
messages: ConversationMessage[];
turnItems: TurnItem[];
};
}
export interface EnvironmentDescriptor {
environmentId: string;
label: string;
serverVersion: string;
orchestrationProtocolVersion?: number;
}
export interface ServerConfig {
environment?: { capabilities?: Record<string, unknown> };
settings?: { defaultModelSelection?: ModelSelection | null };
}
/** Commands sent through `orchestration.dispatchCommand`. */
export type Command =
| {
type: "message.dispatch";
commandId: string;
threadId: string;
messageId: string;
text: string;
attachments: [];
createdBy: "user";
creationSource: "mobile";
dispatchMode: { type: "queue_after_active" };
}
| {
type: "runtime-request.respond";
commandId: string;
threadId: string;
requestId: string;
decision?: ApprovalDecision;
answers?: Record<string, unknown>;
}
| { type: "thread.stop"; commandId: string; threadId: string }
| { type: "run.interrupt"; commandId: string; threadId: string; runId: string };
export interface LaunchThreadInput {
commandId: string;
creationSource: "mobile";
projectId: string;
title: string;
generateTitle: boolean;
modelSelection: ModelSelection;
runtimeMode: string;
interactionMode: "default";
workspaceStrategy: { type: "root" };
initialMessage: { messageId: string; text: string; attachments: [] };
}
/** The protocol version this gateway speaks. A server on another version is refused. */
export const ORCHESTRATION_PROTOCOL_VERSION = 2;