diff --git a/docs/api.md b/docs/api.md
index bef7b70..0d7134a 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -112,7 +112,7 @@ thread". For questions (`kind: "user_input"`), `options` is empty and `questions
```
GET /v1/threads/:hostId/:threadId
-200 { "item": FeedItem, "messages": [Message], "canStop": true }
+200 { "item": FeedItem, "messages": [Message], "canStop": true, "askFirst": false }
Message { "id": "...", "role": "assistant", "text": "...", "createdAt": "..." }
```
@@ -149,6 +149,16 @@ POST /v1/threads/:hostId/:threadId/settle
Settles a thread, as in T3. A settled thread that finished or failed leaves the feed.
+```
+POST /v1/threads/:hostId/:threadId/mode
+{ "askFirst": true }
+202
+```
+
+Turns Ask before acting on or off for a thread. On sets T3's `approval-required` mode. Off sets the
+host's default mode, or `full-access` when that default is also `approval-required`. `askFirst` in
+the thread detail says which mode the thread is in.
+
```
GET /v1/recent
@@ -188,19 +198,28 @@ GET /v1/summary
GET /v1/projects
200 { "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null,
- "projects": [{ "id": "...", "title": "wristcode", "model": "claude-opus-5-5" }] }] }
+ "projects": [{ "id": "...", "title": "wristcode", "model": "claude-opus-5-5", "instanceId": "claudeAgent" }],
+ "providers": [AgentProvider] }] }
+
+AgentProvider { "instanceId": "claudeAgent", "name": "Claude", "canAskFirst": true,
+ "models": [{ "slug": "claude-opus-5-5", "name": "Claude Opus 5.5" }] }
```
-A project with `"model": null` has no default model, so the watch should not offer it.
+`model` and `instanceId` are the project's default, or null when it has none. `providers` lists the
+providers that can start a thread now, without legacy models. `canAskFirst` is false when a provider
+cannot run in `approval-required` mode.
```
POST /v1/threads
-{ "hostId": "...", "projectId": "...", "text": "Add a complication for the waiting count" }
+{ "hostId": "...", "projectId": "...", "text": "Add a complication for the waiting count",
+ "model": { "instanceId": "claudeAgent", "slug": "claude-sonnet-5-5" }, "askFirst": true }
201 { "hostId": "...", "threadId": "..." }
```
-The new thread uses the project's default model, or the host's, and the host's default runtime mode.
+`model` and `askFirst` are optional. Without `model`, the thread uses the project's default model or
+the host's, with its options, such as reasoning effort. A picked model the host does not offer is a
+400. `askFirst` picks the mode the same way as the mode route above.
## Notifications
@@ -218,6 +237,20 @@ its body starts with "Subagent
:", and its `hostId` and `threadId` are th
| `host` | A host's T3 token expires within 7 days, or was rejected | none |
| `test` | The watch asked for a test push | none |
+Each watch picks which pushes it gets. The status route returns its choices, and this route changes
+them:
+
+```
+PUT /v1/device/notifications
+{ "subagents": false }
+
+200 { "needsMe": true, "finished": true, "failed": true, "subagents": false }
+```
+
+Send any of the four. The ones left out keep their value, and all four start on. `needsMe` covers
+`approval` and `input` pushes from your own threads, and `subagents` covers them from subagents.
+`finished` and `failed` match their categories. `host` and `test` pushes always go out.
+
## Status
Everything the settings screens show: each host and its routes, whether the gateway can push, and
@@ -229,7 +262,8 @@ GET /v1/status
200 {
"hosts": [HostStatusDetail],
"gateway": { "startedAt": "...", "pushConfigured": true, "lastPush": PushResult | null },
- "device": { "name": "Apple Watch", "pairedAt": "...", "pushRegistered": true, "apnsEnvironment": "production" }
+ "device": { "name": "Apple Watch", "pairedAt": "...", "pushRegistered": true, "apnsEnvironment": "production",
+ "notifications": NotificationPrefs }
}
HostStatusDetail {
diff --git a/gateway/src/api.ts b/gateway/src/api.ts
index 29ed4fd..ad7e310 100644
--- a/gateway/src/api.ts
+++ b/gateway/src/api.ts
@@ -6,6 +6,7 @@ import type {
ErrorResponse,
NewThreadRequest,
NewThreadResponse,
+ NotificationPrefs,
PairRequest,
PairResponse,
ProjectsResponse,
@@ -15,14 +16,17 @@ import type {
SettleRecentResponse,
SendMessageRequest,
StatusResponse,
+ ThreadModeRequest,
} from "./contract.ts";
import type { Db, DeviceRow } from "./db.ts";
import type { Hub } from "./hub.ts";
-import type { Pusher } from "./push.ts";
+import { notificationPrefs, type Pusher } from "./push.ts";
import { HostError, type T3Host } from "./t3/host.ts";
import { RpcError } from "./t3/rpc.ts";
import type { ApprovalDecision, ModelSelection, ThreadShell } from "./t3/types.ts";
import {
+ agentProviders,
+ ASK_FIRST,
buildFeed,
buildRecent,
buildSummary,
@@ -110,6 +114,29 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
const resolveModel = (host: T3Host, projectId: string): ModelSelection | null =>
host.projects.get(projectId)?.defaultModelSelection ?? host.serverConfig?.settings?.defaultModelSelection ?? null;
+ /** Ask first, or else the host's default mode, or full access when that default also asks first. */
+ const runtimeMode = (host: T3Host, askFirst: boolean): string => {
+ const fallback = host.serverConfig?.settings?.defaultRuntimeMode ?? "full-access";
+ return askFirst ? ASK_FIRST : fallback === ASK_FIRST ? "full-access" : fallback;
+ };
+
+ /**
+ * The model for a new thread: the one the watch picked, if the host offers it, or the project's
+ * default. The default's options, such as reasoning effort, carry over only when it is kept.
+ */
+ const pickModel = (host: T3Host, projectId: string, picked: NewThreadRequest["model"]): ModelSelection => {
+ const fallback = resolveModel(host, projectId);
+ if (picked && !(picked.instanceId === fallback?.instanceId && picked.slug === fallback.model)) {
+ const offered = agentProviders(host)
+ .find((provider) => provider.instanceId === picked.instanceId)
+ ?.models.some((model) => model.slug === picked.slug);
+ if (!offered) throw new HttpError(400, "That model is not available on this host.");
+ return { instanceId: picked.instanceId, model: picked.slug };
+ }
+ if (!fallback) throw new HttpError(400, "That project has no default model. Pick one, or set one in T3 Code.");
+ return fallback;
+ };
+
return {
"/v1/health": {
GET: () => json({ ok: true, hosts: hub.list().map(hostSummary) }),
@@ -156,6 +183,18 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
}),
},
+ "/v1/device/notifications": {
+ PUT: authed<"/v1/device/notifications">(async (req, device) => {
+ const body = await readBody>(req);
+ const prefs = notificationPrefs(device);
+ for (const key of Object.keys(prefs) as Array) {
+ if (typeof body[key] === "boolean") prefs[key] = body[key];
+ }
+ db.devices.setNotifications(device.id, JSON.stringify(prefs));
+ return json(prefs satisfies NotificationPrefs);
+ }),
+ },
+
"/v1/device/test-push": {
POST: authed<"/v1/device/test-push">(async (_req, device) =>
json(
@@ -181,6 +220,7 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
pairedAt: device.created_at,
pushRegistered: device.apns_token !== null,
apnsEnvironment: device.apns_env,
+ notifications: notificationPrefs(device),
},
} satisfies StatusResponse);
}),
@@ -220,16 +260,21 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
},
"/v1/projects": {
- GET: authed(() =>
- json({
+ GET: authed(async () => {
+ await Promise.all(hub.list().map((host) => host.refreshConfig()));
+ return json({
hosts: hub.list().map((host) => ({
...hostSummary(host),
projects: [...host.projects.values()]
.sort((a, b) => a.title.localeCompare(b.title))
- .map((project) => ({ id: project.id, title: project.title, model: resolveModel(host, project.id)?.model ?? null })),
+ .map((project) => {
+ const model = resolveModel(host, project.id);
+ return { id: project.id, title: project.title, model: model?.model ?? null, instanceId: model?.instanceId ?? null };
+ }),
+ providers: agentProviders(host),
})),
- } satisfies ProjectsResponse),
- ),
+ } satisfies ProjectsResponse);
+ }),
},
"/v1/threads": {
@@ -239,8 +284,7 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
if (!text) throw new HttpError(400, "Say what the thread should do.");
const host = findHost(String(body.hostId));
if (!host.projects.has(String(body.projectId))) throw new HttpError(404, "Unknown project.");
- const modelSelection = resolveModel(host, body.projectId);
- if (!modelSelection) throw new HttpError(400, "That project has no default model. Set one in T3 Code.");
+ const modelSelection = pickModel(host, body.projectId, body.model);
const result = await host.launchThread({
commandId: crypto.randomUUID(),
creationSource: "mobile",
@@ -248,7 +292,7 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
title: titleFrom(text),
generateTitle: true,
modelSelection,
- runtimeMode: host.serverConfig?.settings?.defaultRuntimeMode ?? "full-access",
+ runtimeMode: runtimeMode(host, body.askFirst === true),
interactionMode: "default",
workspaceStrategy: { type: "root" },
initialMessage: { messageId: crypto.randomUUID(), text, attachments: [] },
@@ -313,6 +357,21 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
}),
},
+ "/v1/threads/:hostId/:threadId/mode": {
+ POST: authed<"/v1/threads/:hostId/:threadId/mode">(async (req) => {
+ const { host, thread } = findThread(req.params.hostId, req.params.threadId);
+ const { askFirst } = await readBody(req);
+ if (typeof askFirst !== "boolean") throw new HttpError(400, "Send askFirst as true or false.");
+ await host.dispatch({
+ type: "thread.runtime-mode.set",
+ commandId: crypto.randomUUID(),
+ threadId: thread.id,
+ runtimeMode: runtimeMode(host, askFirst),
+ });
+ return accepted();
+ }),
+ },
+
"/v1/threads/:hostId/:threadId/stop": {
POST: authed<"/v1/threads/:hostId/:threadId/stop">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
diff --git a/gateway/src/contract.ts b/gateway/src/contract.ts
index d098dea..52776de 100644
--- a/gateway/src/contract.ts
+++ b/gateway/src/contract.ts
@@ -68,9 +68,21 @@ export interface StatusResponse {
/** 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;
@@ -157,6 +169,12 @@ export interface ThreadDetail {
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;
+}
+
+export interface ThreadModeRequest {
+ askFirst: boolean;
}
export interface ProjectsResponse {
@@ -167,11 +185,24 @@ export interface ProjectsResponse {
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;
@@ -200,6 +231,10 @@ 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 {
diff --git a/gateway/src/db.ts b/gateway/src/db.ts
index b1141a5..9d369a8 100644
--- a/gateway/src/db.ts
+++ b/gateway/src/db.ts
@@ -22,6 +22,8 @@ export interface DeviceRow {
token_hash: string;
apns_token: string | null;
apns_env: "sandbox" | "production" | null;
+ /** The watch's NotificationPrefs as JSON. Null until the watch changes one. */
+ notifications: string | null;
created_at: string;
last_seen_at: string | null;
revoked_at: string | null;
@@ -65,6 +67,8 @@ export function openDb(path: string) {
db.run(SCHEMA);
const hostColumns = db.query<{ name: string }, []>("pragma table_info(hosts)").all().map((column) => column.name);
if (!hostColumns.includes("alt_urls")) db.run("alter table hosts add column alt_urls text");
+ const deviceColumns = db.query<{ name: string }, []>("pragma table_info(devices)").all().map((column) => column.name);
+ if (!deviceColumns.includes("notifications")) db.run("alter table devices add column notifications text");
return {
hosts: {
@@ -107,6 +111,8 @@ export function openDb(path: string) {
apnsEnv,
});
},
+ setNotifications: (id: string, json: string) =>
+ db.query("update devices set notifications = $json where id = $id").run({ id, json }),
clearPush: (apnsToken: string) =>
db.query("update devices set apns_token = null where apns_token = $apnsToken").run({ apnsToken }),
revoke: (idOrName: string) =>
diff --git a/gateway/src/notify.ts b/gateway/src/notify.ts
index b5f5120..d810993 100644
--- a/gateway/src/notify.ts
+++ b/gateway/src/notify.ts
@@ -14,6 +14,8 @@ export interface Push {
subtitle?: string;
body: string;
category: PushCategory;
+ /** True for a subagent's request, which watches can mute apart from their own threads'. */
+ fromSubagent?: boolean;
data: PushPayload;
/** Replaces an earlier push about the same thing on the watch. */
collapseId: string;
@@ -99,6 +101,7 @@ export class Notifier {
subtitle: project ? `${host.label} · ${project}` : host.label,
body: parent ? `Subagent ${thread.title}: ${text}` : text,
category: category(phase),
+ fromSubagent: parent !== undefined,
data: { hostId: host.id, threadId: thread.id, ...(requestId ? { requestId } : {}) },
collapseId: `${host.id}:${thread.id}`.slice(0, 64),
});
diff --git a/gateway/src/push.ts b/gateway/src/push.ts
index 383241d..75b4eb5 100644
--- a/gateway/src/push.ts
+++ b/gateway/src/push.ts
@@ -1,11 +1,35 @@
// Delivers pushes to paired watches through APNs, and remembers Apple's last answer so the watch's
-// status screen can show whether pushes are getting through. Without APNs settings it only logs.
+// status screen can show whether pushes are getting through. Each watch only gets the kinds of push
+// it asked for. Without APNs settings it only logs.
import type { Apns } from "./apns.ts";
-import type { PushResult } from "./contract.ts";
+import type { NotificationPrefs, PushResult } from "./contract.ts";
import type { Db, DeviceRow } from "./db.ts";
import type { Push } from "./notify.ts";
+const ALL_ON: NotificationPrefs = { needsMe: true, finished: true, failed: true, subagents: true };
+
+/** A watch's choices, with every kind it never set left on. */
+export function notificationPrefs(device: DeviceRow): NotificationPrefs {
+ const saved: Partial = device.notifications ? JSON.parse(device.notifications) : {};
+ return { ...ALL_ON, ...saved };
+}
+
+/** Whether a watch with these choices wants this push. Host problems and tests always go out. */
+export function wants(prefs: NotificationPrefs, push: Push): boolean {
+ switch (push.category) {
+ case "approval":
+ case "input":
+ return push.fromSubagent ? prefs.subagents : prefs.needsMe;
+ case "finished":
+ return prefs.finished;
+ case "failed":
+ return prefs.failed;
+ default:
+ return true;
+ }
+}
+
export class Pusher {
/** Apple's answer to the most recent push sent to any watch. */
last: PushResult | null = null;
@@ -20,9 +44,11 @@ export class Pusher {
return this.apns !== null;
}
- /** Sends to every paired watch that registered for pushes. */
+ /** Sends to every paired watch that registered for pushes and wants this kind. */
sendAll = async (push: Push): Promise => {
- const devices = this.db.devices.active().filter((device) => device.apns_token && device.apns_env);
+ const devices = this.db.devices
+ .active()
+ .filter((device) => device.apns_token && device.apns_env && wants(notificationPrefs(device), push));
if (!this.apns || devices.length === 0) {
this.log(`push (not sent): [${push.category}] ${push.title}: ${push.body}`);
return;
diff --git a/gateway/src/t3/host.ts b/gateway/src/t3/host.ts
index caea023..9721518 100644
--- a/gateway/src/t3/host.ts
+++ b/gateway/src/t3/host.ts
@@ -153,6 +153,19 @@ export class T3Host {
return this.requireSocket().call("orchestration.dispatchCommand", command);
}
+ /**
+ * Rereads the server config, for current provider and model lists. Keeps the old one when the
+ * server fails or takes over 3 s.
+ */
+ async refreshConfig(): Promise {
+ if (!this.socket) return;
+ const fresh = await Promise.race([
+ this.socket.call("server.getConfig", {}).catch(() => null),
+ Bun.sleep(3_000).then(() => null),
+ ]);
+ if (fresh) this.serverConfig = fresh;
+ }
+
async launchThread(input: LaunchThreadInput): Promise<{ threadId: string }> {
return this.requireSocket().call("orchestration.launchThread", input);
}
diff --git a/gateway/src/t3/types.ts b/gateway/src/t3/types.ts
index 1e83f03..3062da1 100644
--- a/gateway/src/t3/types.ts
+++ b/gateway/src/t3/types.ts
@@ -158,6 +158,20 @@ export interface EnvironmentDescriptor {
export interface ServerConfig {
environment?: { capabilities?: Record };
settings?: { defaultModelSelection?: ModelSelection | null; defaultRuntimeMode?: string };
+ providers?: ServerProvider[];
+}
+
+/** The parts of a provider in `server.getConfig` the gateway reads. */
+export interface ServerProvider {
+ instanceId: string;
+ driver: string;
+ displayName?: string;
+ enabled: boolean;
+ installed: boolean;
+ status: Open<"ready" | "warning" | "error" | "disabled">;
+ availability?: Open<"available" | "unavailable">;
+ supportedRuntimeModes?: string[];
+ models: Array<{ slug: string; name: string; shortName?: string; isLegacy?: boolean }>;
}
/** Commands sent through `orchestration.dispatchCommand`. */
@@ -183,6 +197,7 @@ export type Command =
}
| { type: "thread.stop"; commandId: string; threadId: string }
| { type: "thread.settle"; commandId: string; threadId: string }
+ | { type: "thread.runtime-mode.set"; commandId: string; threadId: string; runtimeMode: string }
| { type: "run.interrupt"; commandId: string; threadId: string; runId: string };
export interface LaunchThreadInput {
diff --git a/gateway/src/views.ts b/gateway/src/views.ts
index b07e213..821ab30 100644
--- a/gateway/src/views.ts
+++ b/gateway/src/views.ts
@@ -5,6 +5,7 @@
import { threadPhase, type Phase } from "./awareness.ts";
import type {
+ AgentProvider,
Feed,
FeedItem,
HostStatusDetail,
@@ -360,5 +361,28 @@ export async function buildThreadDetail(
item: feedItem(host, thread, child?.phase ?? own, summary, via),
messages: summary.messages,
canStop: thread.activeRunId !== null,
+ askFirst: thread.runtimeMode === ASK_FIRST,
};
}
+
+/** T3's runtime mode that asks before each action. */
+export const ASK_FIRST = "approval-required";
+
+/** The providers on a host that can start a thread now, leaving out legacy models. */
+export function agentProviders(host: T3Host): AgentProvider[] {
+ return (host.serverConfig?.providers ?? [])
+ .filter(
+ (provider) =>
+ provider.enabled &&
+ provider.installed &&
+ provider.availability !== "unavailable" &&
+ (provider.status === "ready" || provider.status === "warning"),
+ )
+ .map((provider) => ({
+ instanceId: provider.instanceId,
+ name: provider.displayName ?? provider.driver,
+ canAskFirst: provider.supportedRuntimeModes?.includes(ASK_FIRST) ?? true,
+ models: provider.models.filter((model) => !model.isLegacy).map((model) => ({ slug: model.slug, name: model.name })),
+ }))
+ .filter((provider) => provider.models.length > 0);
+}
diff --git a/gateway/test/push.test.ts b/gateway/test/push.test.ts
new file mode 100644
index 0000000..3706e5f
--- /dev/null
+++ b/gateway/test/push.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "bun:test";
+
+import type { DeviceRow } from "../src/db.ts";
+import type { Push } from "../src/notify.ts";
+import { notificationPrefs, wants } from "../src/push.ts";
+
+const push = (category: Push["category"], fromSubagent = false): Push => ({
+ title: "Receipt OCR",
+ body: "Finished.",
+ category,
+ fromSubagent,
+ data: { hostId: "host-1", threadId: "thread-1" },
+ collapseId: "host-1:thread-1",
+});
+
+describe("notificationPrefs", () => {
+ it("leaves every kind the watch never set on", () => {
+ const device = { notifications: JSON.stringify({ finished: false }) } as DeviceRow;
+ expect(notificationPrefs(device)).toEqual({ needsMe: true, finished: false, failed: true, subagents: true });
+ });
+});
+
+describe("wants", () => {
+ const prefs = { needsMe: true, finished: false, failed: true, subagents: false };
+
+ it("mutes a subagent's request apart from your own threads' requests", () => {
+ expect(wants(prefs, push("approval"))).toBe(true);
+ expect(wants(prefs, push("approval", true))).toBe(false);
+ expect(wants(prefs, push("input", true))).toBe(false);
+ });
+
+ it("follows the finished and failed switches, and always sends host problems", () => {
+ expect(wants(prefs, push("finished"))).toBe(false);
+ expect(wants(prefs, push("failed"))).toBe(true);
+ expect(wants({ needsMe: false, finished: false, failed: false, subagents: false }, push("host"))).toBe(true);
+ });
+});
diff --git a/gateway/test/views.test.ts b/gateway/test/views.test.ts
index 2316f93..180c4f1 100644
--- a/gateway/test/views.test.ts
+++ b/gateway/test/views.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
import type { BoundedThread, ThreadShell } from "../src/t3/types.ts";
import type { T3Host } from "../src/t3/host.ts";
-import { buildFeed, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts";
+import { agentProviders, buildFeed, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts";
const thread = (overrides: Partial = {}): ThreadShell => ({
id: "thread-1",
@@ -197,3 +197,37 @@ describe("buildFeed", () => {
expect(feed.recentTotal).toBe(11);
});
});
+
+describe("agentProviders", () => {
+ const provider = (overrides: object) => ({
+ instanceId: "claudeAgent",
+ driver: "claudeAgent",
+ displayName: "Claude",
+ enabled: true,
+ installed: true,
+ status: "ready",
+ models: [
+ { slug: "claude-opus-5-5", name: "Claude Opus 5.5" },
+ { slug: "claude-opus-4", name: "Claude Opus 4", isLegacy: true },
+ ],
+ ...overrides,
+ });
+
+ it("offers ready providers without their legacy models, and says which cannot ask first", () => {
+ const host = {
+ serverConfig: {
+ providers: [
+ provider({}),
+ provider({ instanceId: "codex", displayName: undefined, driver: "codex", supportedRuntimeModes: ["full-access"] }),
+ provider({ instanceId: "off", enabled: false }),
+ provider({ instanceId: "broken", status: "error" }),
+ provider({ instanceId: "away", availability: "unavailable" }),
+ ],
+ },
+ } as unknown as T3Host;
+ expect(agentProviders(host)).toEqual([
+ { instanceId: "claudeAgent", name: "Claude", canAskFirst: true, models: [{ slug: "claude-opus-5-5", name: "Claude Opus 5.5" }] },
+ { instanceId: "codex", name: "codex", canAskFirst: false, models: [{ slug: "claude-opus-5-5", name: "Claude Opus 5.5" }] },
+ ]);
+ });
+});
diff --git a/mocks/phase2/a-switches.html b/mocks/phase2/a-switches.html
new file mode 100644
index 0000000..ccc06fc
--- /dev/null
+++ b/mocks/phase2/a-switches.html
@@ -0,0 +1,58 @@
+WristCode Phase 2 A: Settings switches
+
‹ Notifications9:41
+
Notify me when
+
Needs me
+
Finished
+
Failed
+
Subagents ask
+
Notifications. A switch per kind of push, above the existing Send test. Host problems always notify. With Subagents off, their requests still show on the parent's card, only without a buzz.
+
+
New thread9:41
+
Where
Mac mini › wristcode
+
Agent
Claude Opus 5.5›
+
Ask before acting
+🎙 Say the task
+
New thread. The Agent row opens a model list. A switch under it starts the thread in approval-required mode. Both remember your last choice.
+
+
‹ Agent9:41
+
Claude
+
Opus 5.5✓
+
Sonnet 5.5
+
Haiku 5.5
+
Codex
+
GPT-5.5
+
Agent list. Every model the host offers, grouped by provider, project default on top. Unavailable providers stay hidden.
+
+
‹ Thread9:41
+
Working on the gateway route for notification settings…
+Reply
+
Ask before acting
+Stop
+
Thread detail. The same switch on a running thread, so you can rein one in from your wrist. It takes effect on the next tool call.
+
diff --git a/mocks/phase2/b-in-place.html b/mocks/phase2/b-in-place.html
new file mode 100644
index 0000000..34ed720
--- /dev/null
+++ b/mocks/phase2/b-in-place.html
@@ -0,0 +1,54 @@
+WristCode Phase 2 B: Mute where the noise is
+
Feed9:41
+
Laptop · bill-tracker
Receipt OCR
🔕 Mute
+
Desktop · 4m ago
Status screens
+
Mute by swipe. Swipe any card left for Mute. It silences that thread's finished and failed pushes. Requests that need you still buzz.
+
+
‹ Notifications9:41
+
Notify me when
+
AllNeeds me
+
Muted
+
Receipt OCRUnmute
+
bill-trackerUnmute
+
Notifications. One choice, All or Needs me, plus the list of what you muted so you can undo it. A project mutes from its thread's screen.
+
+
New thread9:41
+
Where
Mac mini › wristcode
+
Agent
+
Opus 5.5SonnetGPT-5.5
+
Asks firstFull access
+🎙 Say the task
+
New thread. Models as chips you tap in place, the host's three most used first. Asks first or Full access as a two-way switch. No extra screen.
+
+
‹ Thread9:41
+
Opus 5.5 · Full access
Receipt OCR
+
🔕 MuteAsks first
+Reply
+
Thread detail. Model and mode on the header line. Mute and Asks first as buttons, each flips that thread only.
+