Files
wristcode/gateway/src/contract.ts
T
gib 4dc4e57dac
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s
Gateway: snooze finished threads, and name the files a file approval would change
2026-11-02 14:20:39 -05:00

367 lines
11 KiB
TypeScript

// 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;
}
/**
* How a route reaches its host, guessed from the URL: `public` for HTTPS, `vpn` when the hostname
* says vpn, and `direct` for the rest.
*/
export type RouteKind = "direct" | "vpn" | "public";
export interface Route {
url: string;
kind: RouteKind;
/** From the latest connect. `untried` when an earlier route answered first. */
result: "ok" | "failed" | "untried";
/** Why it failed, such as `refused` or `no answer in 5 s`. */
error: string | null;
checkedAt: string | null;
}
/** One host as the status screens show it. */
export interface HostStatusDetail extends HostSummary {
/** The route in use. Null unless online. */
activeUrl: string | null;
/** Preferred first, the order the gateway tries them. */
routes: Route[];
onlineSince: string | null;
lastOnlineAt: string | null;
/** When the gateway tries again, while it waits to retry. */
nextRetryAt: string | null;
tokenExpiresAt: string | null;
serverVersion: string | null;
}
/** What Apple answered for one push. */
export interface PushResult {
ok: boolean;
at: string;
/** Apple's HTTP status, or 0 when the push never reached Apple. */
status: number;
/** Apple's reason, such as `BadDeviceToken`, or why the gateway could not send. */
reason: string | null;
}
export interface StatusResponse {
hosts: HostStatusDetail[];
gateway: {
startedAt: string;
/** False when the gateway has no APNs key, so nothing can be pushed. */
pushConfigured: boolean;
lastPush: PushResult | null;
};
/** The watch asking. */
device: {
name: string;
pairedAt: string;
/** True once the watch has sent its APNs token. */
pushRegistered: boolean;
apnsEnvironment: "sandbox" | "production" | null;
notifications: NotificationPrefs;
};
}
/**
* Which pushes a watch wants. `needsMe` covers approvals and questions on your own threads, and
* `subagents` covers the same from subagents. Host problems and test pushes always go out.
*/
export interface NotificationPrefs {
needsMe: boolean;
finished: boolean;
failed: boolean;
subagents: boolean;
}
/** 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. `warning` flags a risky choice. */
options: Array<{ decision: ApprovalDecision; label: string; warning?: string }>;
/**
* On `file-change` approvals: the files the agent wants to change. Counts and preview are null
* and empty when the server sends no diff before approval, which is the usual case.
*/
files: PendingFile[];
/** Questions for the user. Empty for approvals. */
questions: Array<{
id: string;
header: string;
question: string;
options: Array<{ label: string; value: string }>;
allowCustomAnswer: boolean;
/** When true, answer with an array of option values. */
multiSelect: boolean;
}>;
}
export interface PendingFile {
path: string;
name: string;
additions: number | null;
deletions: number | null;
/** The first few changed lines, each starting with `+` or `-`. */
preview: string[];
}
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;
/** What a working thread is doing right now. Null when nothing is in progress or not read yet. */
activity: Activity | null;
/** What the latest run changed, in total. Null when it changed no files or is not read yet. */
changes: ChangeTotals | null;
/** When a snoozed thread comes back to Recent. Null when it is not snoozed. */
snoozedUntil: string | null;
request: PendingRequest | null;
/**
* Set when `request` comes from a subagent this thread delegated to, rather than the thread
* itself. Answer the request on that thread. `phase` is then the subagent's.
*/
via: { hostId: string; threadId: string; title: string } | null;
}
/** One step a thread is taking now. */
export interface Activity {
kind: "command" | "edit" | "search" | "web" | "subagent" | "thinking" | "tool";
/** The command line, file name, search, subagent title or tool name, trimmed. */
text: string;
/** When the step started, for its elapsed time. */
startedAt: string | null;
}
export interface ChangeTotals {
files: number;
additions: number;
deletions: number;
}
/** A file the latest run changed. `name` is the last part of `path`. */
export interface ChangedFile {
path: string;
name: string;
additions: number;
deletions: number;
}
/** A thread search hit: the thread, and where the words matched when it was inside a message. */
export interface SearchHit {
thread: ThreadRow;
projectTitle: string;
hostLabel: string;
/** Text around the match in a message, or null when only the title matched. */
snippet: string | null;
}
export interface SearchResponse {
hits: SearchHit[];
/** Labels of hosts that were offline or failed, so their threads were not searched. */
unsearched: string[];
}
/** A thread in a list: a project's threads, or a thread's subagents. */
export interface ThreadRow {
hostId: string;
threadId: string;
title: string;
/** Null for a thread that never ran or was stopped. */
phase: Phase | null;
settled: boolean;
updatedAt: string;
}
export interface ThreadListResponse {
items: ThreadRow[];
}
export interface HistoryResponse {
/** Oldest first, just before the messages the watch already has. */
messages: Message[];
/** Pass to the history route for the page before these. Null at the start of the thread. */
earlierCursor: string | null;
}
/** Counts for the watch face complication. Cheap: no thread pages are read. */
export interface Summary {
needsYou: number;
working: number;
/** The thread that has waited longest for you, if any. */
next: {
hostId: string;
threadId: string;
title: string;
hostLabel: string;
/** The command it wants to run or its first question, trimmed. Null until the gateway has read it. */
request: string | null;
} | null;
/** The working thread updated most recently, with what it is doing. Null when nothing is working. */
current: { hostId: string; threadId: string; title: string; activity: Activity | null } | null;
hostsOffline: number;
}
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 and not yet settled. Most recent first, at most 10. */
recent: FeedItem[];
/** How many threads Recent holds in all. More than `recent.length` means `GET /v1/recent` has the rest. */
recentTotal: number;
/** Finished threads snoozed for later, listed by `GET /v1/snoozed`. */
snoozedTotal: number;
hosts: HostSummary[];
}
export interface RecentResponse {
/** Every unsettled finished thread, most recent first. */
items: FeedItem[];
}
export interface SettleRecentResponse {
settled: number;
/** Threads T3 refused to settle, or whose host was offline. */
failed: number;
}
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;
/** True when the thread runs in approval-required mode, asking before each action. */
askFirst: boolean;
/** Pass to the history route for older messages. Null when `messages` starts the thread. */
earlierCursor: string | null;
/** Subagents and delegated threads this thread started, newest first. */
subagents: ThreadRow[];
/** The files the latest run changed, most lines changed first. */
changedFiles: ChangedFile[];
}
export interface SnoozeRequest {
/** When the thread comes back, in the future and at most 30 days away. */
until: string;
}
export interface ThreadModeRequest {
askFirst: 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;
/** The provider instance `model` belongs to, matching an entry in `providers`. */
instanceId: string | null;
}>;
/** The providers on this host that can start a thread now, each with its models. */
providers: AgentProvider[];
}
>;
}
export interface AgentProvider {
instanceId: string;
/** Such as `Claude` or `Codex`. */
name: string;
/** False when the provider cannot run in approval-required mode. */
canAskFirst: boolean;
models: Array<{ slug: string; name: string }>;
}
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 }
/** Per question id: an option value, a typed answer, or for multi-select questions an array of option values. */
| { requestId: string; answers: Record<string, string | string[]> };
export interface NewThreadRequest {
hostId: string;
projectId: string;
text: string;
/** A model from the host's `providers`. Absent uses the project's default. */
model?: { instanceId: string; slug: string };
/** Starts the thread in approval-required mode. Absent or false uses the host's default mode. */
askFirst?: boolean;
}
export interface NewThreadResponse {
hostId: string;
threadId: string;
}
export interface ErrorResponse {
error: string;
}
/** Custom keys on every push notification, beside `aps`. */
export interface PushPayload {
/** Absent on `test` pushes. */
hostId?: string;
/** Absent on `host` pushes, which are about a host rather than a thread. */
threadId?: string;
requestId?: string;
/**
* On `input` pushes that ask one single-choice question: its options, so the notification can
* offer each as a button. `allowCustomAnswer` adds a spoken answer.
*/
question?: { id: string; options: Array<{ label: string; value: string }>; allowCustomAnswer: boolean };
}
/** Notification categories the watch registers actions for. */
export type PushCategory = "approval" | "finished" | "failed" | "input" | "host" | "test";