Gateway: per-watch notification switches, model list for new threads, and Ask before acting
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 21s

This commit is contained in:
gib committed 2026-11-02 12:02:53 -05:00
1 parent a5222e14e8
commit 3cc7e40852
14 files changed
+472 -20

No files matched your search

+40 -6
View File
@@ -112,7 +112,7 @@ thread". For questions (`kind: "user_input"`), `options` is empty and `questions
``` ```
GET /v1/threads/:hostId/:threadId 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": "..." } 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. 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 GET /v1/recent
@@ -188,19 +198,28 @@ GET /v1/summary
GET /v1/projects GET /v1/projects
200 { "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null, 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 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": "..." } 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 ## Notifications
@@ -218,6 +237,20 @@ its body starts with "Subagent <title>:", and its `hostId` and `threadId` are th
| `host` | A host's T3 token expires within 7 days, or was rejected | none | | `host` | A host's T3 token expires within 7 days, or was rejected | none |
| `test` | The watch asked for a test push | 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 ## Status
Everything the settings screens show: each host and its routes, whether the gateway can push, and Everything the settings screens show: each host and its routes, whether the gateway can push, and
@@ -229,7 +262,8 @@ GET /v1/status
200 { 200 {
"hosts": [HostStatusDetail], "hosts": [HostStatusDetail],
"gateway": { "startedAt": "...", "pushConfigured": true, "lastPush": PushResult | null }, "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 { HostStatusDetail {
+68 -9
View File
@@ -6,6 +6,7 @@ import type {
ErrorResponse, ErrorResponse,
NewThreadRequest, NewThreadRequest,
NewThreadResponse, NewThreadResponse,
NotificationPrefs,
PairRequest, PairRequest,
PairResponse, PairResponse,
ProjectsResponse, ProjectsResponse,
@@ -15,14 +16,17 @@ import type {
SettleRecentResponse, SettleRecentResponse,
SendMessageRequest, SendMessageRequest,
StatusResponse, StatusResponse,
ThreadModeRequest,
} from "./contract.ts"; } from "./contract.ts";
import type { Db, DeviceRow } from "./db.ts"; import type { Db, DeviceRow } from "./db.ts";
import type { Hub } from "./hub.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 { HostError, type T3Host } from "./t3/host.ts";
import { RpcError } from "./t3/rpc.ts"; import { RpcError } from "./t3/rpc.ts";
import type { ApprovalDecision, ModelSelection, ThreadShell } from "./t3/types.ts"; import type { ApprovalDecision, ModelSelection, ThreadShell } from "./t3/types.ts";
import { import {
agentProviders,
ASK_FIRST,
buildFeed, buildFeed,
buildRecent, buildRecent,
buildSummary, buildSummary,
@@ -110,6 +114,29 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
const resolveModel = (host: T3Host, projectId: string): ModelSelection | null => const resolveModel = (host: T3Host, projectId: string): ModelSelection | null =>
host.projects.get(projectId)?.defaultModelSelection ?? host.serverConfig?.settings?.defaultModelSelection ?? 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 { return {
"/v1/health": { "/v1/health": {
GET: () => json({ ok: true, hosts: hub.list().map(hostSummary) }), 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": { "/v1/device/test-push": {
POST: authed<"/v1/device/test-push">(async (_req, device) => POST: authed<"/v1/device/test-push">(async (_req, device) =>
json( json(
@@ -181,6 +220,7 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
pairedAt: device.created_at, pairedAt: device.created_at,
pushRegistered: device.apns_token !== null, pushRegistered: device.apns_token !== null,
apnsEnvironment: device.apns_env, apnsEnvironment: device.apns_env,
notifications: notificationPrefs(device),
}, },
} satisfies StatusResponse); } satisfies StatusResponse);
}), }),
@@ -220,16 +260,21 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
}, },
"/v1/projects": { "/v1/projects": {
GET: authed(() => GET: authed(async () => {
json({ await Promise.all(hub.list().map((host) => host.refreshConfig()));
return json({
hosts: hub.list().map((host) => ({ hosts: hub.list().map((host) => ({
...hostSummary(host), ...hostSummary(host),
projects: [...host.projects.values()] projects: [...host.projects.values()]
.sort((a, b) => a.title.localeCompare(b.title)) .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": { "/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."); if (!text) throw new HttpError(400, "Say what the thread should do.");
const host = findHost(String(body.hostId)); const host = findHost(String(body.hostId));
if (!host.projects.has(String(body.projectId))) throw new HttpError(404, "Unknown project."); if (!host.projects.has(String(body.projectId))) throw new HttpError(404, "Unknown project.");
const modelSelection = resolveModel(host, body.projectId); const modelSelection = pickModel(host, body.projectId, body.model);
if (!modelSelection) throw new HttpError(400, "That project has no default model. Set one in T3 Code.");
const result = await host.launchThread({ const result = await host.launchThread({
commandId: crypto.randomUUID(), commandId: crypto.randomUUID(),
creationSource: "mobile", creationSource: "mobile",
@@ -248,7 +292,7 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
title: titleFrom(text), title: titleFrom(text),
generateTitle: true, generateTitle: true,
modelSelection, modelSelection,
runtimeMode: host.serverConfig?.settings?.defaultRuntimeMode ?? "full-access", runtimeMode: runtimeMode(host, body.askFirst === true),
interactionMode: "default", interactionMode: "default",
workspaceStrategy: { type: "root" }, workspaceStrategy: { type: "root" },
initialMessage: { messageId: crypto.randomUUID(), text, attachments: [] }, 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": { "/v1/threads/:hostId/:threadId/stop": {
POST: authed<"/v1/threads/:hostId/:threadId/stop">(async (req) => { POST: authed<"/v1/threads/:hostId/:threadId/stop">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId); const { host, thread } = findThread(req.params.hostId, req.params.threadId);
+35
View File
@@ -68,9 +68,21 @@ export interface StatusResponse {
/** True once the watch has sent its APNs token. */ /** True once the watch has sent its APNs token. */
pushRegistered: boolean; pushRegistered: boolean;
apnsEnvironment: "sandbox" | "production" | null; 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. */ /** Something a thread is waiting on: an approval, or questions for the user. */
export interface PendingRequest { export interface PendingRequest {
id: string; id: string;
@@ -157,6 +169,12 @@ export interface ThreadDetail {
messages: Message[]; messages: Message[];
/** True while a run is active, so Stop makes sense. */ /** True while a run is active, so Stop makes sense. */
canStop: boolean; 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 { export interface ProjectsResponse {
@@ -167,11 +185,24 @@ export interface ProjectsResponse {
title: string; title: string;
/** The model a new thread will use, such as `claude-opus-5-5`. Null when none is configured. */ /** The model a new thread will use, such as `claude-opus-5-5`. Null when none is configured. */
model: string | null; 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 { export interface PairRequest {
code: string; code: string;
deviceName: string; deviceName: string;
@@ -200,6 +231,10 @@ export interface NewThreadRequest {
hostId: string; hostId: string;
projectId: string; projectId: string;
text: 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 { export interface NewThreadResponse {
+6
View File
@@ -22,6 +22,8 @@ export interface DeviceRow {
token_hash: string; token_hash: string;
apns_token: string | null; apns_token: string | null;
apns_env: "sandbox" | "production" | null; apns_env: "sandbox" | "production" | null;
/** The watch's NotificationPrefs as JSON. Null until the watch changes one. */
notifications: string | null;
created_at: string; created_at: string;
last_seen_at: string | null; last_seen_at: string | null;
revoked_at: string | null; revoked_at: string | null;
@@ -65,6 +67,8 @@ export function openDb(path: string) {
db.run(SCHEMA); db.run(SCHEMA);
const hostColumns = db.query<{ name: string }, []>("pragma table_info(hosts)").all().map((column) => column.name); 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"); 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 { return {
hosts: { hosts: {
@@ -107,6 +111,8 @@ export function openDb(path: string) {
apnsEnv, apnsEnv,
}); });
}, },
setNotifications: (id: string, json: string) =>
db.query("update devices set notifications = $json where id = $id").run({ id, json }),
clearPush: (apnsToken: string) => clearPush: (apnsToken: string) =>
db.query("update devices set apns_token = null where apns_token = $apnsToken").run({ apnsToken }), db.query("update devices set apns_token = null where apns_token = $apnsToken").run({ apnsToken }),
revoke: (idOrName: string) => revoke: (idOrName: string) =>
+3
View File
@@ -14,6 +14,8 @@ export interface Push {
subtitle?: string; subtitle?: string;
body: string; body: string;
category: PushCategory; category: PushCategory;
/** True for a subagent's request, which watches can mute apart from their own threads'. */
fromSubagent?: boolean;
data: PushPayload; data: PushPayload;
/** Replaces an earlier push about the same thing on the watch. */ /** Replaces an earlier push about the same thing on the watch. */
collapseId: string; collapseId: string;
@@ -99,6 +101,7 @@ export class Notifier {
subtitle: project ? `${host.label} · ${project}` : host.label, subtitle: project ? `${host.label} · ${project}` : host.label,
body: parent ? `Subagent ${thread.title}: ${text}` : text, body: parent ? `Subagent ${thread.title}: ${text}` : text,
category: category(phase), category: category(phase),
fromSubagent: parent !== undefined,
data: { hostId: host.id, threadId: thread.id, ...(requestId ? { requestId } : {}) }, data: { hostId: host.id, threadId: thread.id, ...(requestId ? { requestId } : {}) },
collapseId: `${host.id}:${thread.id}`.slice(0, 64), collapseId: `${host.id}:${thread.id}`.slice(0, 64),
}); });
+30 -4
View File
@@ -1,11 +1,35 @@
// Delivers pushes to paired watches through APNs, and remembers Apple's last answer so the watch's // 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 { 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 { Db, DeviceRow } from "./db.ts";
import type { Push } from "./notify.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 { export class Pusher {
/** Apple's answer to the most recent push sent to any watch. */ /** Apple's answer to the most recent push sent to any watch. */
last: PushResult | null = null; last: PushResult | null = null;
@@ -20,9 +44,11 @@ export class Pusher {
return this.apns !== null; 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> => { 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) { if (!this.apns || devices.length === 0) {
this.log(`push (not sent): [${push.category}] ${push.title}: ${push.body}`); this.log(`push (not sent): [${push.category}] ${push.title}: ${push.body}`);
return; return;
+13
View File
@@ -153,6 +153,19 @@ export class T3Host {
return this.requireSocket().call("orchestration.dispatchCommand", command); 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 }> { async launchThread(input: LaunchThreadInput): Promise<{ threadId: string }> {
return this.requireSocket().call("orchestration.launchThread", input); return this.requireSocket().call("orchestration.launchThread", input);
} }
+15
View File
@@ -158,6 +158,20 @@ export interface EnvironmentDescriptor {
export interface ServerConfig { export interface ServerConfig {
environment?: { capabilities?: Record<string, unknown> }; environment?: { capabilities?: Record<string, unknown> };
settings?: { defaultModelSelection?: ModelSelection | null; defaultRuntimeMode?: string }; 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`. */ /** Commands sent through `orchestration.dispatchCommand`. */
@@ -183,6 +197,7 @@ export type Command =
} }
| { type: "thread.stop"; commandId: string; threadId: string } | { type: "thread.stop"; commandId: string; threadId: string }
| { type: "thread.settle"; 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 }; | { type: "run.interrupt"; commandId: string; threadId: string; runId: string };
export interface LaunchThreadInput { export interface LaunchThreadInput {
+24
View File
@@ -5,6 +5,7 @@
import { threadPhase, type Phase } from "./awareness.ts"; import { threadPhase, type Phase } from "./awareness.ts";
import type { import type {
AgentProvider,
Feed, Feed,
FeedItem, FeedItem,
HostStatusDetail, HostStatusDetail,
@@ -360,5 +361,28 @@ export async function buildThreadDetail(
item: feedItem(host, thread, child?.phase ?? own, summary, via), item: feedItem(host, thread, child?.phase ?? own, summary, via),
messages: summary.messages, messages: summary.messages,
canStop: thread.activeRunId !== null, 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);
}
+37
View File
@@ -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);
});
});
+35 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
import type { BoundedThread, ThreadShell } from "../src/t3/types.ts"; import type { BoundedThread, ThreadShell } from "../src/t3/types.ts";
import type { T3Host } from "../src/t3/host.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 => ({ const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
id: "thread-1", id: "thread-1",
@@ -197,3 +197,37 @@ describe("buildFeed", () => {
expect(feed.recentTotal).toBe(11); 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" }] },
]);
});
});
+58
View File
@@ -0,0 +1,58 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 2 A: Settings switches</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
.row { display:flex; align-items:center; gap:7px; }
.name { font-weight:600; font-size:14px; }
.meta { font-size:11px; color:var(--dim); }
.small { font-size:11px; font-weight:600; color:var(--dim); }
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:22px; padding:9px; font-size:14px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.tog { width:30px; height:18px; border-radius:9px; background:#3a3a3c; position:relative; flex:none; margin-left:auto; }
.tog::after { content:""; position:absolute; top:2px; left:2px; width:14px; height:14px; border-radius:50%; background:#fff; }
.tog.on { background:var(--work); } .tog.on::after { left:14px; }
.check { color:var(--work); font-weight:700; margin-left:auto; }
.seg { display:flex; background:#2c2c2e; border-radius:12px; padding:2px; gap:2px; margin-top:5px; }
.seg span { flex:1; text-align:center; font-size:12px; font-weight:700; padding:4px 0; border-radius:10px; color:var(--dim); }
.seg span.sel { background:var(--you); color:#000; }
.chip { display:inline-block; font-size:11px; font-weight:700; border-radius:9px; padding:3px 8px; background:#2c2c2e; color:var(--dim); margin:0 3px 4px 0; }
.chip.sel { background:var(--work); color:#000; }
</style></head><body><div class="grid">
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Notifications</span><span>9:41</span></div>
<div class="label">Notify me when</div>
<div class="card row"><span class="name" style="font-size:13px">Needs me</span><span class="tog on"></span></div>
<div class="card row"><span class="name" style="font-size:13px">Finished</span><span class="tog on"></span></div>
<div class="card row"><span class="name" style="font-size:13px">Failed</span><span class="tog on"></span></div>
<div class="card row"><span class="name" style="font-size:13px">Subagents ask</span><span class="tog"></span></div>
</div></div><figcaption><b>Notifications.</b> 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.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--you)">New thread</span><span>9:41</span></div>
<div class="card" style="--c:var(--done)"><div class="small">Where</div><div class="name">Mac mini › wristcode</div></div>
<div class="card" style="--c:var(--done)"><div class="small">Agent</div><div class="row"><span class="name">Claude Opus 5.5</span><span class="r meta">›</span></div></div>
<div class="card row" style="--c:var(--done)"><span class="name" style="font-size:13px">Ask before acting</span><span class="tog on" style="background:var(--you)"></span></div>
<span class="big" style="background:var(--you);color:#000">🎙 Say the task</span>
</div></div><figcaption><b>New thread.</b> The Agent row opens a model list. A switch under it starts the thread in approval-required mode. Both remember your last choice.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Agent</span><span>9:41</span></div>
<div class="label">Claude</div>
<div class="card row"><span class="name" style="font-size:13px">Opus 5.5</span><span class="check">✓</span></div>
<div class="card row"><span class="name" style="font-size:13px">Sonnet 5.5</span></div>
<div class="card row"><span class="name" style="font-size:13px">Haiku 5.5</span></div>
<div class="label">Codex</div>
<div class="card row"><span class="name" style="font-size:13px">GPT-5.5</span></div>
</div></div><figcaption><b>Agent list.</b> Every model the host offers, grouped by provider, project default on top. Unavailable providers stay hidden.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
<div class="meta" style="padding:0 6px 6px">Working on the gateway route for notification settings…</div>
<span class="big" style="background:var(--card)">Reply</span>
<div class="card row"><span class="name" style="font-size:13px">Ask before acting</span><span class="tog"></span></div>
<span class="big" style="background:#2c2c2e;color:var(--bad)">Stop</span>
</div></div><figcaption><b>Thread detail.</b> The same switch on a running thread, so you can rein one in from your wrist. It takes effect on the next tool call.</figcaption></figure>
</div></body></html>
+54
View File
@@ -0,0 +1,54 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 2 B: Mute where the noise is</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
.row { display:flex; align-items:center; gap:7px; }
.name { font-weight:600; font-size:14px; }
.meta { font-size:11px; color:var(--dim); }
.small { font-size:11px; font-weight:600; color:var(--dim); }
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:22px; padding:9px; font-size:14px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.tog { width:30px; height:18px; border-radius:9px; background:#3a3a3c; position:relative; flex:none; margin-left:auto; }
.tog::after { content:""; position:absolute; top:2px; left:2px; width:14px; height:14px; border-radius:50%; background:#fff; }
.tog.on { background:var(--work); } .tog.on::after { left:14px; }
.check { color:var(--work); font-weight:700; margin-left:auto; }
.seg { display:flex; background:#2c2c2e; border-radius:12px; padding:2px; gap:2px; margin-top:5px; }
.seg span { flex:1; text-align:center; font-size:12px; font-weight:700; padding:4px 0; border-radius:10px; color:var(--dim); }
.seg span.sel { background:var(--you); color:#000; }
.chip { display:inline-block; font-size:11px; font-weight:700; border-radius:9px; padding:3px 8px; background:#2c2c2e; color:var(--dim); margin:0 3px 4px 0; }
.chip.sel { background:var(--work); color:#000; }
</style></head><body><div class="grid">
<figure><div class="bezel"><div class="screen"><div class="top"><span>Feed</span><span>9:41</span></div>
<div style="display:flex;margin-bottom:5px"><div class="card" style="--c:var(--work);flex:1;margin:0;border-radius:16px 0 0 16px"><div class="small" style="color:var(--work)">Laptop · bill-tracker</div><div class="name">Receipt OCR</div></div><div style="width:58px;background:#3a3a3c;border-radius:0 16px 16px 0;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:11px;font-weight:700">🔕<br>Mute</div></div>
<div class="card" style="--c:var(--done)"><div class="small" style="color:var(--done)">Desktop · 4m ago</div><div class="name" style="color:var(--dim)">Status screens</div></div>
</div></div><figcaption><b>Mute by swipe.</b> Swipe any card left for Mute. It silences that thread's finished and failed pushes. Requests that need you still buzz.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Notifications</span><span>9:41</span></div>
<div class="label">Notify me when</div>
<div class="seg" style="margin:0 0 6px"><span class="sel" style="background:var(--work)">All</span><span>Needs me</span></div>
<div class="label">Muted</div>
<div class="card row"><span class="name" style="font-size:13px">Receipt OCR</span><span class="r meta">Unmute</span></div>
<div class="card row"><span class="name" style="font-size:13px">bill-tracker</span><span class="r meta">Unmute</span></div>
</div></div><figcaption><b>Notifications.</b> 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.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--you)">New thread</span><span>9:41</span></div>
<div class="card" style="--c:var(--done)"><div class="small">Where</div><div class="name">Mac mini › wristcode</div></div>
<div class="small" style="padding:2px 4px 3px">Agent</div>
<div style="padding:0 2px"><span class="chip sel">Opus 5.5</span><span class="chip">Sonnet</span><span class="chip">GPT-5.5</span></div>
<div class="seg" style="margin:2px 2px 6px"><span class="sel">Asks first</span><span>Full access</span></div>
<span class="big" style="background:var(--you);color:#000">🎙 Say the task</span>
</div></div><figcaption><b>New thread.</b> 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.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
<div class="card" style="--c:var(--work)"><div class="small" style="color:var(--work)">Opus 5.5 · Full access</div><div class="name">Receipt OCR</div></div>
<div class="row" style="gap:5px;margin-bottom:5px"><span class="big" style="flex:1;background:#2c2c2e;font-size:12px;margin:0">🔕 Mute</span><span class="big" style="flex:1;background:#2c2c2e;font-size:12px;margin:0">Asks first</span></div>
<span class="big" style="background:var(--card)">Reply</span>
</div></div><figcaption><b>Thread detail.</b> Model and mode on the header line. Mute and Asks first as buttons, each flips that thread only.</figcaption></figure>
</div></body></html>
+54
View File
@@ -0,0 +1,54 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 2 C: Presets</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
.row { display:flex; align-items:center; gap:7px; }
.name { font-weight:600; font-size:14px; }
.meta { font-size:11px; color:var(--dim); }
.small { font-size:11px; font-weight:600; color:var(--dim); }
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:22px; padding:9px; font-size:14px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.tog { width:30px; height:18px; border-radius:9px; background:#3a3a3c; position:relative; flex:none; margin-left:auto; }
.tog::after { content:""; position:absolute; top:2px; left:2px; width:14px; height:14px; border-radius:50%; background:#fff; }
.tog.on { background:var(--work); } .tog.on::after { left:14px; }
.check { color:var(--work); font-weight:700; margin-left:auto; }
.seg { display:flex; background:#2c2c2e; border-radius:12px; padding:2px; gap:2px; margin-top:5px; }
.seg span { flex:1; text-align:center; font-size:12px; font-weight:700; padding:4px 0; border-radius:10px; color:var(--dim); }
.seg span.sel { background:var(--you); color:#000; }
.chip { display:inline-block; font-size:11px; font-weight:700; border-radius:9px; padding:3px 8px; background:#2c2c2e; color:var(--dim); margin:0 3px 4px 0; }
.chip.sel { background:var(--work); color:#000; }
</style></head><body><div class="grid">
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Notifications</span><span>9:41</span></div>
<div class="label">Buzz me for</div>
<div class="card row" style="--c:var(--work)"><div><div class="name" style="font-size:13px">Everything</div><div class="meta">Needs me, done, failed</div></div><span class="check">✓</span></div>
<div class="card row"><div><div class="name" style="font-size:13px">Needs me and failures</div></div></div>
<div class="card row"><div><div class="name" style="font-size:13px">Only needs me</div></div></div>
</div></div><figcaption><b>Notifications.</b> One pick from three levels, instead of switches. Subagent requests count as needs me in every level.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--you)">New thread</span><span>9:41</span></div>
<div class="card" style="--c:var(--done)"><div class="small">Where</div><div class="name">Mac mini › wristcode</div></div>
<div class="card" style="--c:var(--done)"><div class="small">Setup</div><div class="row"><span class="name" style="font-size:13px">Opus 5.5 · Asks first</span><span class="r meta">›</span></div></div>
<span class="big" style="background:var(--you);color:#000">🎙 Say the task</span>
</div></div><figcaption><b>New thread.</b> The Agent row becomes Setup and names both choices. One tap opens a sheet with the model and the mode.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Setup</span><span>9:41</span></div>
<div class="label">Model</div>
<div class="card row"><span class="name" style="font-size:13px">Opus 5.5</span><span class="check">✓</span></div>
<div class="card row"><span class="name" style="font-size:13px">Sonnet 5.5</span></div>
<div class="label">Mode</div>
<div class="card row" style="--c:var(--you)"><div><div class="name" style="font-size:13px">Asks first</div><div class="meta">Approve each command</div></div><span class="check">✓</span></div>
</div></div><figcaption><b>Setup sheet.</b> Models, then all four T3 modes: Asks first, Auto-accept edits, Auto, Full access. Scrolls with the crown.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
<div class="meta" style="padding:0 6px 6px">Working on the gateway route for notification settings…</div>
<span class="big" style="background:var(--card)">Reply</span>
<div class="card" style="--c:var(--done)"><div class="small">Mode</div><div class="row"><span class="name" style="font-size:13px">Full access</span><span class="r meta">›</span></div></div>
</div></div><figcaption><b>Thread detail.</b> A Mode row at the bottom opens the same four-way list for the running thread. The model stays as it started.</figcaption></figure>
</div></body></html>