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<Partial<NotificationPrefs>>(req); + const prefs = notificationPrefs(device); + for (const key of Object.keys(prefs) as Array<keyof NotificationPrefs>) { + 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<ThreadModeRequest>(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<NotificationPrefs> = 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<void> => { - 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<void> { + if (!this.socket) return; + const fresh = await Promise.race([ + this.socket.call<ServerConfig>("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<string, unknown> }; 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> = {}): 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 @@ +<!doctype html><html><head><meta charset="utf-8"><title>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.
+
diff --git a/mocks/phase2/c-presets.html b/mocks/phase2/c-presets.html new file mode 100644 index 0000000..2ff9cd8 --- /dev/null +++ b/mocks/phase2/c-presets.html @@ -0,0 +1,54 @@ +WristCode Phase 2 C: Presets
+
‹ Notifications9:41
+
Buzz me for
+
Everything
Needs me, done, failed
✓
+
Needs me and failures
+
Only needs me
+
Notifications. One pick from three levels, instead of switches. Subagent requests count as needs me in every level.
+ +
New thread9:41
+
Where
Mac mini › wristcode
+
Setup
Opus 5.5 · Asks first›
+🎙 Say the task +
New thread. The Agent row becomes Setup and names both choices. One tap opens a sheet with the model and the mode.
+ +
‹ Setup9:41
+
Model
+
Opus 5.5✓
+
Sonnet 5.5
+
Mode
+
Asks first
Approve each command
✓
+
Setup sheet. Models, then all four T3 modes: Asks first, Auto-accept edits, Auto, Full access. Scrolls with the crown.
+ +
‹ Thread9:41
+
Working on the gateway route for notification settings…
+Reply +
Mode
Full access›
+
Thread detail. A Mode row at the bottom opens the same four-way list for the running thread. The model stays as it started.
+