Gateway: show subagent requests on the parent's card, and settle threads
Gateway / check (push) Successful in 8s
Gateway / image (push) Successful in 21s

This commit is contained in:
gib committed 2026-11-01 20:15:22 -05:00
1 parent a8b6375afe
commit a4754b65b0
7 files changed
+183 -21

No files matched your search

+20 -2
View File
@@ -75,10 +75,17 @@ FeedItem {
"phase": "waiting_for_approval", "phase": "waiting_for_approval",
"updatedAt": "2026-11-01T09:41:00.000Z", "updatedAt": "2026-11-01T09:41:00.000Z",
"lastMessage": "Parser and tests are written. I want to run the suite.", "lastMessage": "Parser and tests are written. I want to run the suite.",
"request": PendingRequest | null "request": PendingRequest | null,
"via": { "hostId": "...", "threadId": "...", "title": "Check Panama PATH" } | null
} }
``` ```
Threads an agent started for another thread (subagents and `delegate_task` children) do not get
their own cards. When one waits on you, its request moves onto the card of the thread that started
it. `via` names the subagent, `request` and `phase` are the subagent's, and the answer goes to the
subagent: `POST /v1/threads/<via.hostId>/<via.threadId>/respond`. A subagent keeps its own card
when the gateway cannot find its parent, or the parent already shows a request.
`phase` is one of `starting`, `running`, `waiting_for_approval`, `waiting_for_input`, `completed`, or `phase` is one of `starting`, `running`, `waiting_for_approval`, `waiting_for_input`, `completed`, or
`failed`. `lastMessage` is the agent's latest message trimmed to about 280 characters. It can be null `failed`. `lastMessage` is the agent's latest message trimmed to about 280 characters. It can be null
for a moment after a thread changes. for a moment after a thread changes.
@@ -133,6 +140,16 @@ POST /v1/threads/:hostId/:threadId/stop
202 202
``` ```
```
POST /v1/threads/:hostId/:threadId/settle
202
```
Settles a thread, as in T3. A settled thread that finished or failed leaves the feed. To settle all
of Recent, settle each item.
Thread detail fills `request` and `via` the same way as the feed when a subagent waits on you.
## Summary ## Summary
The counts behind the watch face complication. It reads no thread pages, so it is cheap to call. The counts behind the watch face complication. It reads no thread pages, so it is cheap to call.
@@ -170,7 +187,8 @@ The new thread uses the project's default model, or the host's, and the host's d
The gateway pushes when a thread starts waiting for you, finishes, or fails. Each push has an The gateway pushes when a thread starts waiting for you, finishes, or fails. Each push has an
`aps.category` and the custom keys `hostId`, `threadId` (absent on `host` pushes), and, for `aps.category` and the custom keys `hostId`, `threadId` (absent on `host` pushes), and, for
approvals and questions, `requestId`. A `test` push carries no custom keys. approvals and questions, `requestId`. A `test` push carries no custom keys. A subagent's push is titled with the thread that started it,
its body starts with "Subagent <title>:", and its `hostId` and `threadId` are the subagent's.
| Category | When | Actions the watch registers | | Category | When | Actions the watch registers |
| --- | --- | --- | | --- | --- | --- |
+9 -1
View File
@@ -232,7 +232,7 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
"/v1/threads/:hostId/:threadId": { "/v1/threads/:hostId/:threadId": {
GET: authed<"/v1/threads/:hostId/:threadId">(async (req) => { GET: authed<"/v1/threads/:hostId/:threadId">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId); const { host, thread } = findThread(req.params.hostId, req.params.threadId);
return json(await buildThreadDetail(host, thread, cache)); return json(await buildThreadDetail(host, thread, cache, hub.list()));
}), }),
}, },
@@ -277,6 +277,14 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
}), }),
}, },
"/v1/threads/:hostId/:threadId/settle": {
POST: authed<"/v1/threads/:hostId/:threadId/settle">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
await host.dispatch({ type: "thread.settle", commandId: crypto.randomUUID(), threadId: thread.id });
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);
+5
View File
@@ -105,6 +105,11 @@ export interface FeedItem {
/** The agent's latest message, trimmed for the watch. Null until the gateway has read it. */ /** The agent's latest message, trimmed for the watch. Null until the gateway has read it. */
lastMessage: string | null; lastMessage: string | null;
request: PendingRequest | null; request: PendingRequest | null;
/**
* Set when `request` comes from a subagent this thread delegated to, rather than the thread
* itself. Answer the request on that thread. `phase` is then the subagent's.
*/
via: { hostId: string; threadId: string; title: string } | null;
} }
/** Counts for the watch face complication. Cheap: no thread pages are read. */ /** Counts for the watch face complication. Cheap: no thread pages are read. */
+5 -2
View File
@@ -91,10 +91,13 @@ export class Notifier {
]); ]);
const project = host.projects.get(thread.projectId)?.title; const project = host.projects.get(thread.projectId)?.title;
const requestId = thread.pendingRuntimeRequest?.id; const requestId = thread.pendingRuntimeRequest?.id;
// A subagent's push is about the thread that delegated to it, which is the one the user knows.
const parent = thread.delegatedFrom?.title;
const text = body(phase, thread.lastError ?? null, summary);
await this.send({ await this.send({
title: thread.title, title: parent ?? thread.title,
subtitle: project ? `${host.label} · ${project}` : host.label, subtitle: project ? `${host.label} · ${project}` : host.label,
body: body(phase, thread.lastError ?? null, summary), body: parent ? `Subagent ${thread.title}: ${text}` : text,
category: category(phase), category: category(phase),
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),
+1
View File
@@ -182,6 +182,7 @@ export type Command =
answers?: Record<string, unknown>; answers?: Record<string, unknown>;
} }
| { type: "thread.stop"; commandId: string; threadId: string } | { type: "thread.stop"; commandId: string; threadId: string }
| { type: "thread.settle"; commandId: string; threadId: 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 {
+90 -15
View File
@@ -180,7 +180,56 @@ export function routeKind(url: string): RouteKind {
return hostname.includes("vpn") ? "vpn" : "direct"; return hostname.includes("vpn") ? "vpn" : "direct";
} }
function feedItem(host: T3Host, thread: ThreadShell, phase: Phase, summary: ThreadSummary | undefined): FeedItem { /** A thread on the feed. With `via`, the card shows that subagent's request on this thread. */
interface Candidate {
host: T3Host;
thread: ThreadShell;
phase: Phase;
via?: { host: T3Host; thread: ThreadShell; phase: Phase };
}
const isWaiting = (phase: Phase | null) => phase === "waiting_for_approval" || phase === "waiting_for_input";
/** The thread that delegated `child`, if the gateway is connected to its host. */
function delegator(hosts: T3Host[], child: ThreadShell): { host: T3Host; thread: ThreadShell } | null {
const from = child.delegatedFrom;
if (!from) return null;
const host = hosts.find((candidate) => candidate.config.environmentId === from.environmentId);
const thread = host?.threads.get(from.threadId);
return host && thread ? { host, thread } : null;
}
/**
* Moves each waiting subagent's request onto the card of the thread that delegated it, the oldest
* request first. A subagent keeps its own card when its parent is unknown, hidden, archived, or
* already showing a request.
*/
function attachDelegates(candidates: Candidate[], hosts: T3Host[]): Candidate[] {
const result = [...candidates];
const delegates = candidates
.filter(({ thread, phase }) => thread.delegatedFrom && isWaiting(phase))
.sort((a, b) => requestAge(a.thread).localeCompare(requestAge(b.thread)));
for (const child of delegates) {
const parent = delegator(hosts, child.thread);
const index = parent ? result.findIndex(({ host, thread }) => host === parent.host && thread.id === parent.thread.id) : -1;
const card = result[index];
const own = result.indexOf(child);
if (!card || own === -1 || card.via || isWaiting(card.phase)) continue;
result[index] = { ...card, phase: child.phase, via: { host: child.host, thread: child.thread, phase: child.phase } };
result.splice(own, 1);
}
return result;
}
const requestAge = (thread: ThreadShell) => thread.pendingRuntimeRequest?.createdAt ?? "";
function feedItem(
host: T3Host,
thread: ThreadShell,
phase: Phase,
summary: ThreadSummary | undefined,
via?: { host: T3Host; thread: ThreadShell; summary: ThreadSummary | undefined },
): FeedItem {
return { return {
hostId: host.id, hostId: host.id,
hostLabel: host.label, hostLabel: host.label,
@@ -191,10 +240,26 @@ function feedItem(host: T3Host, thread: ThreadShell, phase: Phase, summary: Thre
phase, phase,
updatedAt: thread.updatedAt, updatedAt: thread.updatedAt,
lastMessage: summary?.lastAssistantMessage ?? null, lastMessage: summary?.lastAssistantMessage ?? null,
request: summary?.request ?? null, request: (via ? via.summary : summary)?.request ?? null,
via: via ? { hostId: via.host.id, threadId: via.thread.id, title: via.thread.title } : null,
}; };
} }
/** The longest waiting subagent that `parent` delegated to, if any. */
function waitingDelegate(hosts: T3Host[], parentHost: T3Host, parent: ThreadShell): Candidate | undefined {
let oldest: Candidate | undefined;
for (const host of hosts) {
for (const thread of host.threads.values()) {
const from = thread.delegatedFrom;
if (from?.threadId !== parent.id || from.environmentId !== parentHost.config.environmentId) continue;
const phase = threadPhase(thread);
if (!isWaiting(phase) || thread.archivedAt || thread.deletedAt) continue;
if (!oldest || requestAge(thread) < requestAge(oldest.thread)) oldest = { host, thread, phase: phase! };
}
}
return oldest;
}
/** Counts for the complication, from thread shells alone. */ /** Counts for the complication, from thread shells alone. */
export function buildSummary(hosts: T3Host[]): Summary { export function buildSummary(hosts: T3Host[]): Summary {
let needsYou = 0; let needsYou = 0;
@@ -221,37 +286,37 @@ export function buildSummary(hosts: T3Host[]): Summary {
/** Builds the feed across every host, waiting briefly for summaries it has not read yet. */ /** Builds the feed across every host, waiting briefly for summaries it has not read yet. */
export async function buildFeed(hosts: T3Host[], cache: SummaryCache, now = Date.now()): Promise<Feed> { export async function buildFeed(hosts: T3Host[], cache: SummaryCache, now = Date.now()): Promise<Feed> {
const candidates: Array<{ host: T3Host; thread: ThreadShell; phase: Phase }> = []; const found: Candidate[] = [];
for (const host of hosts) { for (const host of hosts) {
for (const thread of host.threads.values()) { for (const thread of host.threads.values()) {
const phase = threadPhase(thread); const phase = threadPhase(thread);
if (phase === null || thread.archivedAt || thread.deletedAt) continue; if (phase === null || thread.archivedAt || thread.deletedAt) continue;
if ((phase === "completed" || phase === "failed") && now - Date.parse(thread.updatedAt) > RECENT_WINDOW_MS) continue; if ((phase === "completed" || phase === "failed") && now - Date.parse(thread.updatedAt) > RECENT_WINDOW_MS) continue;
candidates.push({ host, thread, phase }); found.push({ host, thread, phase });
} }
} }
const candidates = attachDelegates(found, hosts);
const byNewest = (a: { thread: ThreadShell }, b: { thread: ThreadShell }) => const byNewest = (a: { thread: ThreadShell }, b: { thread: ThreadShell }) =>
b.thread.updatedAt.localeCompare(a.thread.updatedAt); b.thread.updatedAt.localeCompare(a.thread.updatedAt);
const asker = (candidate: Candidate) => candidate.via?.thread ?? candidate.thread;
const needsYou = candidates const needsYou = candidates
.filter(({ phase }) => phase === "waiting_for_approval" || phase === "waiting_for_input") .filter(({ phase }) => isWaiting(phase))
.sort((a, b) => .sort((a, b) => requestAge(asker(a)).localeCompare(requestAge(asker(b))));
(a.thread.pendingRuntimeRequest?.createdAt ?? "").localeCompare(b.thread.pendingRuntimeRequest?.createdAt ?? ""),
);
const working = candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest); const working = candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest);
const recent = candidates const recent = candidates
.filter(({ phase }) => phase === "completed" || phase === "failed") .filter(({ phase }) => phase === "completed" || phase === "failed")
.sort(byNewest) .sort(byNewest)
.slice(0, RECENT_LIMIT); .slice(0, RECENT_LIMIT);
const shown = [...needsYou, ...working, ...recent]; const shown = [...needsYou, ...working, ...recent].flatMap((candidate) => (candidate.via ? [candidate, candidate.via] : [candidate]));
const missing = shown const missing = shown
.filter(({ host, thread }) => !cache.peek(host, thread)) .filter(({ host, thread }) => !cache.peek(host, thread))
.map(({ host, thread }) => cache.get(host, thread).catch(() => undefined)); .map(({ host, thread }) => cache.get(host, thread).catch(() => undefined));
if (missing.length > 0) await Promise.race([Promise.all(missing), Bun.sleep(SUMMARY_WAIT_MS)]); if (missing.length > 0) await Promise.race([Promise.all(missing), Bun.sleep(SUMMARY_WAIT_MS)]);
const toItem = ({ host, thread, phase }: (typeof candidates)[number]) => const toItem = ({ host, thread, phase, via }: Candidate) =>
feedItem(host, thread, phase, cache.peek(host, thread)); feedItem(host, thread, phase, cache.peek(host, thread), via && { ...via, summary: cache.peek(via.host, via.thread) });
return { return {
needsYou: needsYou.map(toItem), needsYou: needsYou.map(toItem),
working: working.map(toItem), working: working.map(toItem),
@@ -260,12 +325,22 @@ export async function buildFeed(hosts: T3Host[], cache: SummaryCache, now = Date
}; };
} }
/** One thread with its recent messages. Fetches the summary if it is not cached. */ /**
export async function buildThreadDetail(host: T3Host, thread: ThreadShell, cache: SummaryCache): Promise<ThreadDetail> { * 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.
*/
export async function buildThreadDetail(
host: T3Host,
thread: ThreadShell,
cache: SummaryCache,
hosts: T3Host[],
): Promise<ThreadDetail> {
const summary = await cache.get(host, thread); const summary = await cache.get(host, thread);
const phase = threadPhase(thread) ?? (thread.status === "failed" ? "failed" : "completed"); const own = threadPhase(thread) ?? (thread.status === "failed" ? "failed" : "completed");
const child = isWaiting(own) ? undefined : waitingDelegate(hosts, host, thread);
const via = child && { host: child.host, thread: child.thread, summary: await cache.get(child.host, child.thread).catch(() => undefined) };
return { return {
item: feedItem(host, thread, phase, summary), item: feedItem(host, thread, child?.phase ?? own, summary, via),
messages: summary.messages, messages: summary.messages,
canStop: thread.activeRunId !== null, canStop: thread.activeRunId !== null,
}; };
+53 -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 { buildSummary, routeKind, summarize, trimText } from "../src/views.ts"; import { 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",
@@ -138,3 +138,55 @@ describe("routeKind", () => {
expect(routeKind("https://t3laptopvpn.gbrown.org")).toBe("public"); expect(routeKind("https://t3laptopvpn.gbrown.org")).toBe("public");
}); });
}); });
describe("buildFeed", () => {
const now = Date.parse("2026-11-01T09:30:00.000Z");
const host = (label: string, threads: ThreadShell[]) =>
({
id: label,
label,
status: "online",
config: { environmentId: `env-${label}` },
projects: new Map(),
threads: new Map(threads.map((t) => [t.id, t])),
bounded: () => Promise.reject(new Error("not needed")),
}) as unknown as T3Host;
const ask = (id: string, minute: number) => ({ id, kind: "command", createdAt: `2026-11-01T09:${minute}:00.000Z` });
const delegatedFrom = { environmentId: "env-Desktop", threadId: "parent", title: "HomeLab Stats" };
it("shows a waiting subagent's request on the card of the thread that delegated it", async () => {
const feed = await buildFeed(
[
host("Desktop", [thread({ id: "parent", title: "HomeLab Stats" })]),
host("Mac mini", [
thread({ id: "child", title: "Check PATH", delegatedFrom, pendingRuntimeRequest: ask("r1", 10) }),
thread({ id: "busy", title: "Survey setup", delegatedFrom }),
]),
],
new SummaryCache(),
now,
);
expect(feed.working).toEqual([]);
expect(feed.needsYou.map((item) => [item.threadId, item.phase, item.via])).toEqual([
["parent", "waiting_for_approval", { hostId: "Mac mini", threadId: "child", title: "Check PATH" }],
]);
});
it("keeps a second waiting subagent on its own card", async () => {
const feed = await buildFeed(
[
host("Desktop", [thread({ id: "parent", title: "HomeLab Stats" })]),
host("Mac mini", [
thread({ id: "later", delegatedFrom, pendingRuntimeRequest: ask("r2", 20) }),
thread({ id: "first", delegatedFrom, pendingRuntimeRequest: ask("r1", 10) }),
]),
],
new SummaryCache(),
now,
);
expect(feed.needsYou.map((item) => [item.threadId, item.via?.threadId ?? null])).toEqual([
["parent", "first"],
["later", null],
]);
});
});