diff --git a/docs/api.md b/docs/api.md index 5f40b06..fa27a69 100644 --- a/docs/api.md +++ b/docs/api.md @@ -59,14 +59,16 @@ GET /v1/feed "working": [FeedItem], "recent": [FeedItem], "recentTotal": 12, + "snoozedTotal": 2, "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null }] } ``` `needsYou` holds threads waiting for an approval or an answer, oldest first. `working` holds running threads, most recently updated first. `recent` holds threads that completed or failed and are not -settled yet, however old, newest first and at most 10. `recentTotal` counts all of them. When it is -larger, the rest come from `GET /v1/recent`. Host `status` is one of `connecting`, `online`, `offline`, `auth_invalid`, or +settled or snoozed yet, however old, newest first and at most 10. `recentTotal` counts all of them. When it is +larger, the rest come from `GET /v1/recent`. `snoozedTotal` counts finished threads snoozed for +later, which `GET /v1/snoozed` lists. Host `status` is one of `connecting`, `online`, `offline`, `auth_invalid`, or `incompatible`. ``` @@ -79,6 +81,7 @@ FeedItem { "lastMessage": "Parser and tests are written. I want to run the suite.", "activity": { "kind": "command", "text": "bun test", "startedAt": "..." } | null, "changes": { "files": 3, "additions": 42, "deletions": 8 } | null, + "snoozedUntil": "..." | null, "request": PendingRequest | null, "via": { "hostId": "...", "threadId": "...", "title": "Check Panama PATH" } | null } @@ -110,10 +113,17 @@ PendingRequest { "prompt": "bun test --filter ocr", "reason": null, "options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }], + "files": [], "questions": [] } ``` +For `kind: "file-change"`, `files` lists the files the agent wants to change as +`{ path, name, additions, deletions, preview }`. `name` is the last part of `path`. Most providers, +Claude included, send the diff only after the edit runs, so `additions` and `deletions` are usually +null and `preview` is empty. When a diff comes with the request, `preview` holds its first 4 +changed lines, each starting with `+` or `-`. + An option may also carry `warning`, a short note about a risky choice such as "Applies to every thread". For questions (`kind: "user_input"`), `options` is empty and `questions` lists `{ id, header, question, options: [{ label, value }], allowCustomAnswer, multiSelect }`. @@ -205,6 +215,27 @@ POST /v1/recent/settle Settles every completed thread in Recent, including those past the first 10. Failed threads stay. `failed` counts threads T3 refused or whose host was offline. +``` +GET /v1/snoozed + +200 { "items": [FeedItem] } +``` + +Finished threads snoozed for later, the soonest to come back first, with `snoozedUntil` set. Like +`GET /v1/recent`, it uses only cached summaries. + +``` +POST /v1/threads/:hostId/:threadId/snooze +{ "until": "2026-11-03T14:00:00.000Z" } +202 + +POST /v1/threads/:hostId/:threadId/unsnooze +202 +``` + +Snoozing hides a finished thread from Recent until `until`, which must be in the next 30 days. Then +it comes back on its own. A snoozed thread that starts working or waiting on you shows as usual. + Thread detail fills `request` and `via` the same way as the feed when a subagent waits on you. ## Summary @@ -294,7 +325,7 @@ its body starts with "Subagent :", and its `hostId` and `threadId` are th | --- | --- | --- | | `approval` | A thread needs an approval | Allow (`accept`), Deny (`decline`) | | `input` | A thread asked a question | Answer, Open. With `question`: one button per option, Say an answer (text input) when `allowCustomAnswer`, Open | -| `finished` | A turn completed | Reply (text input), Open | +| `finished` | A turn completed | Reply (text input), Settle, Open | | `failed` | A turn failed | Open | | `host` | A host's T3 token expires within 7 days, or was rejected | none | | `test` | The watch asked for a test push | none | diff --git a/gateway/src/api.ts b/gateway/src/api.ts index ad363c6..f71fa79 100644 --- a/gateway/src/api.ts +++ b/gateway/src/api.ts @@ -14,6 +14,7 @@ import type { RespondRequest, PushResult, SettleRecentResponse, + SnoozeRequest, SendMessageRequest, StatusResponse, ThreadListResponse, @@ -30,6 +31,7 @@ import { ASK_FIRST, buildFeed, buildRecent, + buildSnoozed, buildSummary, buildThreadDetail, feedCandidates, @@ -71,6 +73,8 @@ class HttpError extends Error { } } +const SNOOZE_MAX_MS = 30 * 24 * 60 * 60_000; + const json = (body: unknown, status = 200) => Response.json(body, { status }); const accepted = () => new Response(null, { status: 202 }); @@ -259,6 +263,10 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {} }), }, + "/v1/snoozed": { + GET: authed(() => json(buildSnoozed(hub.list(), cache))), + }, + "/v1/summary": { GET: authed(async () => json(await buildSummary(hub.list(), cache))), }, @@ -401,6 +409,31 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {} }), }, + "/v1/threads/:hostId/:threadId/snooze": { + POST: authed<"/v1/threads/:hostId/:threadId/snooze">(async (req) => { + const { host, thread } = findThread(req.params.hostId, req.params.threadId); + const until = Date.parse(String((await readBody<SnoozeRequest>(req)).until ?? "")); + if (!(until > Date.now() && until - Date.now() <= SNOOZE_MAX_MS)) { + throw new HttpError(400, "Snooze until a time in the next 30 days."); + } + await host.dispatch({ + type: "thread.snooze", + commandId: crypto.randomUUID(), + threadId: thread.id, + snoozedUntil: new Date(until).toISOString(), + }); + return accepted(); + }), + }, + + "/v1/threads/:hostId/:threadId/unsnooze": { + POST: authed<"/v1/threads/:hostId/:threadId/unsnooze">(async (req) => { + const { host, thread } = findThread(req.params.hostId, req.params.threadId); + await host.dispatch({ type: "thread.unsnooze", commandId: crypto.randomUUID(), threadId: thread.id, reason: "user" }); + 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 c90f48c..d5f56d4 100644 --- a/gateway/src/contract.ts +++ b/gateway/src/contract.ts @@ -93,6 +93,11 @@ export interface PendingRequest { reason: string | null; /** Approval choices, in the server's order. Empty for questions. `warning` flags a risky choice. */ options: Array<{ decision: ApprovalDecision; label: string; warning?: string }>; + /** + * On `file-change` approvals: the files the agent wants to change. Counts and preview are null + * and empty when the server sends no diff before approval, which is the usual case. + */ + files: PendingFile[]; /** Questions for the user. Empty for approvals. */ questions: Array<{ id: string; @@ -105,6 +110,15 @@ export interface PendingRequest { }>; } +export interface PendingFile { + path: string; + name: string; + additions: number | null; + deletions: number | null; + /** The first few changed lines, each starting with `+` or `-`. */ + preview: string[]; +} + export interface FeedItem { hostId: string; hostLabel: string; @@ -120,6 +134,8 @@ export interface FeedItem { activity: Activity | null; /** What the latest run changed, in total. Null when it changed no files or is not read yet. */ changes: ChangeTotals | null; + /** When a snoozed thread comes back to Recent. Null when it is not snoozed. */ + snoozedUntil: string | null; request: PendingRequest | null; /** * Set when `request` comes from a subagent this thread delegated to, rather than the thread @@ -215,6 +231,8 @@ export interface Feed { recent: FeedItem[]; /** How many threads Recent holds in all. More than `recent.length` means `GET /v1/recent` has the rest. */ recentTotal: number; + /** Finished threads snoozed for later, listed by `GET /v1/snoozed`. */ + snoozedTotal: number; hosts: HostSummary[]; } @@ -252,6 +270,11 @@ export interface ThreadDetail { changedFiles: ChangedFile[]; } +export interface SnoozeRequest { + /** When the thread comes back, in the future and at most 30 days away. */ + until: string; +} + export interface ThreadModeRequest { askFirst: boolean; } diff --git a/gateway/src/t3/types.ts b/gateway/src/t3/types.ts index 79f6dea..d0e1c99 100644 --- a/gateway/src/t3/types.ts +++ b/gateway/src/t3/types.ts @@ -72,6 +72,8 @@ export interface ThreadShell { /** Set on threads an agent started with delegate_task, which T3 does not mark as subagents. */ delegatedFrom?: { environmentId: string; threadId: string; title: string }; settledOverride?: Open<"settled"> | null; + /** Set while the user has snoozed the thread. It stays set after the time passes. */ + snoozedUntil?: string | null; } export interface ShellSnapshot { @@ -128,6 +130,8 @@ export interface TurnItem { fileName?: string; additions?: number; deletions?: number; + /** A unified diff, on `file_change` once the edit has run. */ + diffStr?: string; /** The search, on `file_search`. */ pattern?: string; /** The tool, on `dynamic_tool`. */ @@ -235,6 +239,8 @@ export type Command = } | { type: "thread.stop"; commandId: string; threadId: string } | { type: "thread.settle"; commandId: string; threadId: string } + | { type: "thread.snooze"; commandId: string; threadId: string; snoozedUntil: string } + | { type: "thread.unsnooze"; commandId: string; threadId: string; reason: "user" } | { type: "thread.runtime-mode.set"; commandId: string; threadId: string; runtimeMode: string } | { type: "run.interrupt"; commandId: string; threadId: string; runId: string }; diff --git a/gateway/src/views.ts b/gateway/src/views.ts index 2269f59..5688aab 100644 --- a/gateway/src/views.ts +++ b/gateway/src/views.ts @@ -8,6 +8,7 @@ import type { Activity, AgentProvider, ChangedFile, + PendingFile, Feed, FeedItem, HostStatusDetail, @@ -185,7 +186,7 @@ function describeStep(item: TurnItem): Omit<Activity, "startedAt"> | null { function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projection"]["turnItems"]): PendingRequest | null { const pending = thread.pendingRuntimeRequest; if (!pending || pending.kind === "auth_refresh") return null; - const base = { id: pending.id, kind: pending.kind, prompt: null, reason: null, options: [], questions: [] }; + const base = { id: pending.id, kind: pending.kind, prompt: null, reason: null, options: [], files: [], questions: [] }; const item = turnItems.findLast( (candidate) => (isApprovalRequest(candidate) || isUserInputRequest(candidate)) && candidate.requestId === pending.id, ); @@ -196,7 +197,9 @@ function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projectio { decision: "accept" as const, label: "Allow" }, { decision: "decline" as const, label: "Deny" }, ]; - return { ...base, prompt: item.prompt ?? item.title ?? null, reason: item.reason ?? null, options }; + const prompt = item.prompt ?? item.title ?? null; + const files = pending.kind === "file-change" ? pendingFiles(turnItems, item.runId, prompt) : []; + return { ...base, prompt, reason: item.reason ?? null, options, files }; } if (item && isUserInputRequest(item)) { return { @@ -223,6 +226,44 @@ function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projectio }; } +const PREVIEW_LINES = 4; +const PREVIEW_CHARS = 60; + +/** + * The files a file-change approval would touch: the run's unfinished file changes, or failing + * those, the path in the prompt, which Claude words as `Edit: <path>`. Most providers send the + * diff only after the edit runs, so counts and preview are usually empty. + */ +export function pendingFiles(turnItems: TurnItem[], runId: string | null | undefined, prompt: string | null): PendingFile[] { + const files = turnItems + .filter( + (item) => + item.type === "file_change" && + item.fileName && + (!runId || item.runId === runId) && + item.status !== "completed" && + item.status !== "failed" && + item.status !== "cancelled", + ) + .map((item) => { + const preview = (item.diffStr ?? "") + .split("\n") + .filter((line) => /^[+-]/.test(line) && !/^(\+\+\+|---)( |$)/.test(line)) + .slice(0, PREVIEW_LINES) + .map((line) => (line.length > PREVIEW_CHARS ? `${line.slice(0, PREVIEW_CHARS)}…` : line)); + return { + path: item.fileName!, + name: item.fileName!.split("/").pop()!, + additions: item.additions ?? null, + deletions: item.deletions ?? null, + preview, + }; + }); + if (files.length > 0) return files; + const path = prompt?.match(/^\w+: (\S.*)$/)?.[1]?.trim(); + return path ? [{ path, name: path.split("/").pop()!, additions: null, deletions: null, preview: [] }] : []; +} + /** Per-thread summaries, refetched when a thread's `updatedAt` changes. */ export class SummaryCache { private readonly entries = new Map<string, { updatedAt: string; fetchedAt: number; summary: ThreadSummary }>(); @@ -361,6 +402,7 @@ function feedItem( updatedAt: thread.updatedAt, lastMessage: summary?.lastAssistantMessage ?? null, activity: summary?.activity ?? null, + snoozedUntil: isSnoozed(thread, Date.now()) ? thread.snoozedUntil! : null, changes: summary?.changedFiles.length ? { files: summary.changedFiles.length, @@ -442,8 +484,12 @@ export async function buildSummary(hosts: T3Host[], cache: SummaryCache): Promis }; } +/** True while a snooze is set and its time has not come. */ +const isSnoozed = (thread: ThreadShell, now: number) => + !!thread.snoozedUntil && Date.parse(thread.snoozedUntil) > now; + /** Every thread the feed shows, in feed order, with subagent requests on their parents' cards. */ -export function feedCandidates(hosts: T3Host[]) { +export function feedCandidates(hosts: T3Host[], now = Date.now()) { const found: Candidate[] = []; for (const host of hosts) { for (const thread of host.threads.values()) { @@ -457,13 +503,18 @@ export function feedCandidates(hosts: T3Host[]) { const byNewest = (a: { thread: ThreadShell }, b: { thread: ThreadShell }) => b.thread.updatedAt.localeCompare(a.thread.updatedAt); const asker = (candidate: Candidate) => candidate.via?.thread ?? candidate.thread; + const finished = candidates.filter(({ phase }) => phase === "completed" || phase === "failed"); return { needsYou: candidates .filter(({ phase }) => isWaiting(phase)) .sort((a, b) => requestAge(asker(a)).localeCompare(requestAge(asker(b)))), working: candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest), - /** Finished and failed threads not yet settled, however old. */ - recent: candidates.filter(({ phase }) => phase === "completed" || phase === "failed").sort(byNewest), + /** Finished and failed threads not yet settled or snoozed, however old. */ + recent: finished.filter(({ thread }) => !isSnoozed(thread, now)).sort(byNewest), + /** Finished threads snoozed for later, the soonest to come back first. */ + snoozed: finished + .filter(({ thread }) => isSnoozed(thread, now)) + .sort((a, b) => a.thread.snoozedUntil!.localeCompare(b.thread.snoozedUntil!)), }; } @@ -474,7 +525,7 @@ const toItem = /** Builds the feed across every host, waiting briefly for summaries it has not read yet. */ export async function buildFeed(hosts: T3Host[], cache: SummaryCache): Promise<Feed> { - const { needsYou, working, recent: allRecent } = feedCandidates(hosts); + const { needsYou, working, recent: allRecent, snoozed } = feedCandidates(hosts); const recent = allRecent.slice(0, RECENT_LIMIT); const shown = [...needsYou, ...working, ...recent].flatMap((candidate) => (candidate.via ? [candidate, candidate.via] : [candidate])); @@ -488,6 +539,7 @@ export async function buildFeed(hosts: T3Host[], cache: SummaryCache): Promise<F working: working.map(toItem(cache)), recent: recent.map(toItem(cache)), recentTotal: allRecent.length, + snoozedTotal: snoozed.length, hosts: hosts.map(hostSummary), }; } @@ -500,6 +552,11 @@ export function buildRecent(hosts: T3Host[], cache: SummaryCache): RecentRespons return { items: feedCandidates(hosts).recent.map(toItem(cache)) }; } +/** Snoozed finished threads, the soonest to come back first. Cached summaries only, like Recent. */ +export function buildSnoozed(hosts: T3Host[], cache: SummaryCache): RecentResponse { + return { items: feedCandidates(hosts).snoozed.map(toItem(cache)) }; +} + /** * One thread with its recent messages. Fetches the summary if it is not cached. When the thread * has no request of its own but a subagent it delegated to is waiting, shows that request. diff --git a/gateway/test/views.test.ts b/gateway/test/views.test.ts index ea62b9d..248a791 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 { agentProviders, buildFeed, currentActivity, latestChanges, searchThreads, loadHistory, projectThreads, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts"; +import { agentProviders, buildFeed, currentActivity, feedCandidates, latestChanges, pendingFiles, searchThreads, loadHistory, projectThreads, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts"; const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({ id: "thread-1", @@ -73,6 +73,7 @@ describe("summarize", () => { { decision: "acceptAlways", label: "Always allow", warning: "Applies to every thread" }, { decision: "decline", label: "Deny" }, ], + files: [], questions: [], }); }); @@ -343,3 +344,48 @@ describe("searchThreads", () => { expect(result.unsearched).toEqual(["Laptop"]); }); }); + +describe("pendingFiles", () => { + it("lists the run's unfinished file changes with whatever counts and diff the server sent", () => { + const files = pendingFiles( + [ + { type: "file_change", runId: "run-2", status: "completed", fileName: "done.ts" }, + { type: "file_change", runId: "run-2", status: "pending", fileName: "deploy/compose.yml", additions: 2, deletions: 1, diffStr: "--- a/compose.yml\n+++ b/compose.yml\n@@ -1 +1,2 @@\n- image: bun:1\n+ image: bun:1-alpine\n+ restart: always" }, + ], + "run-2", + "Edit: deploy/compose.yml", + ); + expect(files).toEqual([ + { path: "deploy/compose.yml", name: "compose.yml", additions: 2, deletions: 1, preview: ["- image: bun:1", "+ image: bun:1-alpine", "+ restart: always"] }, + ]); + }); + + it("falls back to the path in Claude's prompt when no change is in the window", () => { + expect(pendingFiles([], "run-1", "Edit: /home/me/app/src/main.ts")).toEqual([ + { path: "/home/me/app/src/main.ts", name: "main.ts", additions: null, deletions: null, preview: [] }, + ]); + }); +}); + +describe("snoozed threads", () => { + it("leave Recent until their time comes, the soonest first", () => { + const now = Date.parse("2026-11-01T12:00:00.000Z"); + const host = { + id: "Desktop", + label: "Desktop", + threads: new Map( + [ + thread({ id: "done", status: "completed" }), + thread({ id: "later", status: "completed", snoozedUntil: "2026-11-02T09:00:00.000Z" }), + thread({ id: "soon", status: "completed", snoozedUntil: "2026-11-01T13:00:00.000Z" }), + thread({ id: "woke", status: "completed", snoozedUntil: "2026-11-01T11:00:00.000Z" }), + thread({ id: "busy", status: "running", snoozedUntil: "2026-11-02T09:00:00.000Z" }), + ].map((t) => [t.id, t]), + ), + } as unknown as T3Host; + const { recent, snoozed, working } = feedCandidates([host], now); + expect(recent.map(({ thread }) => thread.id).sort()).toEqual(["done", "woke"]); + expect(snoozed.map(({ thread }) => thread.id)).toEqual(["soon", "later"]); + expect(working.map(({ thread }) => thread.id)).toEqual(["busy"]); + }); +}); diff --git a/mocks/phase5/a-snooze.html b/mocks/phase5/a-snooze.html new file mode 100644 index 0000000..568427d --- /dev/null +++ b/mocks/phase5/a-snooze.html @@ -0,0 +1,42 @@ +<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 5: Snooze
+

Option 1: a second swipe action beside Settle

+
Recent9:41
+
Mac mini · 4m ago
Gateway Containerfile
☾
Snooze
✓
Settle
+
Desktop · 1h ago
Explain the hub
+
Swipe left. Snooze sits beside Settle, in violet.
+
Snooze until9:41
+In 1 hourThis evening · 6 PMTomorrow · 9 AMNext week +
Pick a time. Four fixed choices. The card leaves Recent and comes back on its own when the time is up.
+
9:41
+
Desktop · 1h ago
Explain the hub
+
☾ 2 snoozed›
+
Snoozed row. Under Recent, a dim row counts snoozed threads and opens their list, each with when it wakes and Unsnooze.
+

Option 2: snooze from the thread screen only

+
‹ Thread9:41
+
Done. The image builds and the healthcheck passes.
+Reply +
SettleSnooze
+
Beside Settle. Cards keep their one swipe action. Snooze is a button next to Settle on the thread screen, with the same time picker.
+
diff --git a/mocks/phase5/b-file-approval.html b/mocks/phase5/b-file-approval.html new file mode 100644 index 0000000..192bbb6 --- /dev/null +++ b/mocks/phase5/b-file-approval.html @@ -0,0 +1,47 @@ +WristCode Phase 5: File approvals
+

Option 1: file and line counts on the card

+
29:41
+
Mac mini · wristcode
Gateway Containerfile
+
Wants to edit
compose.yml+9 −6
+
AllowDeny
+
Feed card. "Wants to edit", then the file name and how many lines it adds and removes. Same on the thread screen and the notification.
+
‹ Thread9:41
+
Wants to edit
compose.yml+9 −6
+
− image: bun:1
+ image: bun:1-alpine
+ restart: always
+
AllowDeny
+
Thread screen. The same, plus the first few changed lines, red and green. Enough to catch a wrong file or a wild edit.
+
‹ Thread9:41
+
Wants to edit 3 files
+
compose.yml+9 −6
+
Containerfile+31 −0
+
health.ts+2 −2
+
Several files. One row each, with no line preview.
+

Option 2: file name only

+
29:41
+
Mac mini · wristcode
Gateway Containerfile
+
Wants to edit compose.yml
+
AllowDeny
+
Just the name. No counts and no preview. Simplest, and it works even when T3 sends no diff.
+