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(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 | 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: `. 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();
@@ -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 {
- 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 = {}): 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 @@
+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.
+