From f3066596200072199190e0be55c1a49d3076a0ce Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Fri, 23 Oct 2026 23:03:59 -0700
Subject: [PATCH 1/3] chore(quest): claim quest/m0/kix/activity.md
Quest-Claim: b24cc0e8a8f705f1
From 86daad70bc298d973cb6b8b26744c8d4caf13b64 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Fri, 23 Oct 2026 23:21:52 -0700
Subject: [PATCH 2/3] feat(kix): show what agents are working on as summary
cards
The inbox gets an Agents column beside the decisions (below them on a
phone): one card per job with its target, state, runner and account, the
agent's latest checkpoint summary as escaped text, and how long ago it was
written. A working job quiet for 15 minutes says "No update since" instead
of guessing. Raw output loads only when a maintainer opens a card's
disclosure, through the existing output route that rechecks maintainer
authority on every request. An enrolled project's quest log charts its tree
as the quest map and rings the quests those jobs work on.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/README.md | 14 +++
cloudflare/src/app.js | 42 +++++++
cloudflare/src/board/pages.tsx | 9 +-
cloudflare/src/inbox/activity.ts | 125 +++++++++++++++++++
cloudflare/src/inbox/model.ts | 37 ++++--
cloudflare/src/inbox/page.tsx | 208 ++++++++++++++++++++++---------
cloudflare/src/index.tsx | 18 ++-
cloudflare/src/jobs/store.ts | 19 +++
cloudflare/src/map.tsx | 27 ++--
cloudflare/src/site.css | 77 +++++++++++-
cloudflare/test/inbox.test.ts | 142 ++++++++++++++++++++-
quest/m0/kix/README.md | 1 -
quest/m0/kix/activity.md | 30 -----
quest/m0/kix/execution.md | 1 -
14 files changed, 630 insertions(+), 120 deletions(-)
create mode 100644 cloudflare/src/inbox/activity.ts
delete mode 100644 quest/m0/kix/activity.md
diff --git a/cloudflare/README.md b/cloudflare/README.md
index 96e98596..ffd1f705 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -216,6 +216,20 @@ awaits a decision, and any other whose issue, pull request, or quest is still
observed, so finished work stays set aside. Retrying a failed planning
interview keeps its `plans/` target.
+Beside the decisions (below them on a phone), an Agents card shows each job
+on the maintainer's projects (`src/inbox/activity.ts`): every target's latest
+job while it is queued, running, or waiting, and an ended one while the pull
+request it delivered is open and no later job works on that pull request. A
+card names the job's target and state (queued, working, runner offline,
+waiting on you, account limited, PR open, or stopped), its runner and account,
+the agent's latest checkpoint or result summary as escaped text, and how long
+ago it wrote it. A working job silent for 15 minutes says "No update since"
+instead of guessing. Each card stands in for its job's in-progress row. Raw
+output stays behind the card's disclosure: `/app.js` fetches the output route
+above only once a maintainer opens it, and shows it as text. An enrolled
+project's quest log also charts its tree as the quest map, ringing the quests
+those same jobs work on.
+
A user's projects are those in `github_project_members`. Enrolling a project
adds its enroller, and any other user joins by passing the maintainer check on
one of its pages, such as its setup page; no extra GitHub calls list
diff --git a/cloudflare/src/app.js b/cloudflare/src/app.js
index 2aa4ab57..34baf087 100644
--- a/cloudflare/src/app.js
+++ b/cloudflare/src/app.js
@@ -62,6 +62,48 @@ for (const form of document.querySelectorAll('form[action="/sign-out"]')) {
);
}
+// A job's raw output loads only when a maintainer opens its disclosure, a page
+// at a time, and the server rechecks their authority on every page. It is
+// shown as text, never markup.
+for (const details of document.querySelectorAll("details[data-output]")) {
+ const output = details.querySelector("pre");
+ const status = details.querySelector("[data-output-status]");
+ const more = details.querySelector("[data-output-more]");
+ let next = null;
+ let started = false;
+ async function load() {
+ more.disabled = true;
+ try {
+ const url = new URL(details.dataset.output, location.href);
+ if (next) url.searchParams.set("after", `${next.attempt}.${next.seq}`);
+ const response = await fetch(url, { headers: { Accept: "application/json" } });
+ const page = await response.json().catch(() => null);
+ if (!response.ok) throw new Error(page?.error || "The raw output could not be read.");
+ output.append(page.output.map((chunk) => chunk.text).join(""));
+ output.hidden = !output.textContent;
+ next = page.next;
+ more.hidden = !next;
+ status.textContent = !output.textContent
+ ? "No raw output is stored for this job."
+ : page.truncated.length
+ ? "Some output was dropped at the size limit."
+ : "";
+ } catch (error) {
+ status.textContent = error.message || "Something went wrong.";
+ // Opening the disclosure again retries a first page that failed.
+ if (!output.textContent) started = false;
+ } finally {
+ more.disabled = false;
+ }
+ }
+ details.addEventListener("toggle", () => {
+ if (!details.open || started) return;
+ started = true;
+ load();
+ });
+ more.addEventListener("click", load);
+}
+
// Whether the server notifies this browser's subscription for whoever is
// signed in. It forgets one when a session signs out, and never hands one to
// another user, so the browser's own subscription is not enough to show "on".
diff --git a/cloudflare/src/board/pages.tsx b/cloudflare/src/board/pages.tsx
index c39389dd..ee599138 100644
--- a/cloudflare/src/board/pages.tsx
+++ b/cloudflare/src/board/pages.tsx
@@ -2,6 +2,7 @@ import type { Child } from "hono/jsx";
import { raw } from "hono/html";
import { type Doc, entries, rooted } from "quest/core";
import { type Crumb, Difficulty, Marker, Paste, SignIn, Token, type User } from "../layout";
+import { QuestMap } from "../map";
import { render } from "./markdown";
import {
type Board,
@@ -191,7 +192,11 @@ function Tally(props: { board: Board; show: Status["kind"] | null }) {
);
}
-export function BoardPage(props: { board: Board; show: Status["kind"] | null }) {
+/**
+ * A project's quest log. An enrolled project's also charts its tree as the
+ * quest map, highlighting the quests `agents` work on.
+ */
+export function BoardPage(props: { board: Board; show: Status["kind"] | null; agents?: Map }) {
const { board, show } = props;
const { project } = board;
const recent = board.finished.slice(0, 8);
@@ -223,6 +228,8 @@ export function BoardPage(props: { board: Board; show: Status["kind"] | null })
)}
+ {props.agents && }
+
{milestones.map(({ milestone, items }) => (
))}
diff --git a/cloudflare/src/inbox/activity.ts b/cloudflare/src/inbox/activity.ts
new file mode 100644
index 00000000..faf0eb51
--- /dev/null
+++ b/cloudflare/src/inbox/activity.ts
@@ -0,0 +1,125 @@
+// Agent cards: what each job on a maintainer's projects is doing, told by the
+// agent's own checkpoint summaries. Summaries are untrusted agent text, shown
+// as escaped text and never read as an answer. Raw output is not part of a
+// card; it loads behind a per-job disclosure that rechecks maintainer authority.
+
+import { parse } from "quest/core";
+import type { Enrollment } from "../github/service";
+import { type Item, type JobView, type ProjectView, type Runners, entities, fallback } from "./model";
+
+/** How long a working job may go without a summary before its card says so, rather than guessing. */
+export const QUIET = 15 * 60_000;
+
+/**
+ * Where a job stands: waiting for a runner, working, on a runner that went
+ * offline, waiting on a maintainer's answer, paused on a limited account, or
+ * ended with the pull request it delivered still open, either finished or stopped.
+ */
+export type Phase = "queued" | "working" | "offline" | "waiting" | "limited" | "pull" | "stopped";
+
+export const PHASES: Record = {
+ queued: "Queued",
+ working: "Working",
+ offline: "Runner offline",
+ waiting: "Waiting on you",
+ limited: "Account limited",
+ pull: "PR open",
+ stopped: "Stopped",
+};
+
+export type Card = {
+ project: Enrollment;
+ job: JobView;
+ /** The target the job works on: its issue, pull request, or quest. */
+ title: string;
+ href: string;
+ /** The quest the job works on, which the quest map highlights. */
+ quest: string | null;
+ phase: Phase;
+ runner: string | null;
+ account: string | null;
+ /** The pull request the job delivered. */
+ pull: { number: number; href: string } | null;
+ /** When the agent last wrote a summary, or when the job was requested. */
+ at: number;
+ /** A working job silent for longer than `QUIET`. */
+ quiet: boolean;
+ /** The inbox item for the job's target, where its thread and stop control live. */
+ item: Item | null;
+};
+
+export function phase(job: JobView, runners: Runners): Phase {
+ if (job.state === "queued") return "queued";
+ if (job.state === "running") {
+ const runner = runners.find((r) => r.id === job.runner);
+ return job.lease !== null || !runner?.online ? "offline" : "working";
+ }
+ if (job.state === "waiting") {
+ const question = job.questions.find((q) => q.attempt === job.attempt && q.approvedAt === null);
+ return question?.kind === "account" && question.answer === null ? "limited" : "waiting";
+ }
+ return job.state === "completed" ? "pull" : "stopped";
+}
+
+// Cards that wait on a maintainer come first, then trouble, then work under way.
+const ORDER: Phase[] = ["waiting", "limited", "offline", "working", "queued", "stopped", "pull"];
+
+/** The cards for each project's jobs, with `items` from the same inbox load. */
+export function cards(
+ views: ProjectView[],
+ jobs: JobView[][],
+ runners: Map,
+ items: Item[],
+ now: number,
+): Card[] {
+ return views
+ .flatMap((view, i) => {
+ const { project, state } = view;
+ const found = entities(project, state);
+ const web = `https://github.com/${project.owner}/${project.name}`;
+ return jobs[i]!.map((job): Card => {
+ const own = runners.get(job.actor) ?? [];
+ const runner = own.find((r) => r.id === job.runner);
+ const entity = found.get(job.target) ?? fallback(project, job.target);
+ const quest = entity.kind === "quest" ? job.target : null;
+ // A claimed quest is no longer an observed entity, so its title comes from the tree.
+ const document = quest ? state?.project.documents.find((d) => d.path === quest) : undefined;
+ const summaries = job.events.filter((e) => e.kind === "summary" || e.kind === "result");
+ const at = summaries.at(-1)?.at ?? job.createdAt;
+ const current = phase(job, own);
+ return {
+ project,
+ job,
+ title: document ? (parse(document.path, document.content).title?.text ?? quest!) : entity.title,
+ href: entity.href,
+ quest,
+ phase: current,
+ runner: runner?.name ?? null,
+ account: runner?.accounts.find((a) => a.id === job.account)?.label ?? null,
+ pull: job.pull === null ? null : { number: job.pull, href: `${web}/pull/${job.pull}` },
+ at,
+ quiet: current === "working" && now - at > QUIET,
+ item: items.find((it) => it.project.id === project.id && it.target === job.target) ?? null,
+ };
+ });
+ })
+ .sort((a, b) => ORDER.indexOf(a.phase) - ORDER.indexOf(b.phase) || b.at - a.at);
+}
+
+/** How long ago `at` was, roughly: just now, minutes, hours, then days. */
+export function ago(now: number, at: number): string {
+ const minutes = Math.max(0, Math.round((now - at) / 60_000));
+ if (minutes === 0) return "just now";
+ const [count, unit] =
+ minutes < 60
+ ? [minutes, "minute"]
+ : minutes < 48 * 60
+ ? [Math.round(minutes / 60), "hour"]
+ : [Math.round(minutes / 1440), "day"];
+ return `${count} ${unit}${count === 1 ? "" : "s"} ago`;
+}
+
+/** The quest map's highlights: what the agent on each quest is doing, by the quest's path. */
+export function highlights(list: Card[]) {
+ return new Map(list.flatMap((card) => (card.quest ? [[card.quest, PHASES[card.phase]] as const] : [])));
+}
diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts
index a963c110..fb499c76 100644
--- a/cloudflare/src/inbox/model.ts
+++ b/cloudflare/src/inbox/model.ts
@@ -112,7 +112,8 @@ function held(entity: Pick) {
return entity.kind === "pull" && !!entity.activity?.data && owned(entity.activity.data as Pull);
}
-function fallback(project: Enrollment, target: string): Entity {
+/** What an item is called and links to when nothing observed names its target. */
+export function fallback(project: Enrollment, target: string): Entity {
const web = `https://github.com/${project.owner}/${project.name}`;
const kind = kindOf(target);
const number = target.split("/")[1];
@@ -264,22 +265,36 @@ export async function load(env: Env, user: string) {
}),
);
const projects = views.filter((view) => view !== null);
- const items = await collect(
- env.DB,
- user,
- projects.filter((view) => !view.error),
- );
- // Jobs run on their requester's runners, so presence comes from each requester's hub.
- const actors = [...new Set(items.flatMap((item) => (item.job ? [item.job.actor] : [])))];
- const runners = new Map(
+ const readable = projects.filter((view) => !view.error);
+ const [items, activity] = await Promise.all([
+ collect(env.DB, user, readable),
+ Promise.all(readable.map((view) => store.listActivityJobs(env.DB, view.project.id, openPulls(view)))),
+ ]);
+ const actors = [
+ ...items.flatMap((item) => (item.job ? [item.job.actor] : [])),
+ ...activity.flat().map((j) => j.actor),
+ ];
+ return { projects, readable, items, activity, runners: await presence(env, actors) };
+}
+
+/** The open pull requests the project's observations list. */
+export function openPulls(view: ProjectView) {
+ return [...entities(view.project, view.state).keys()].flatMap((target) => {
+ const number = target.match(/^pulls\/(\d+)$/)?.[1];
+ return number ? [Number(number)] : [];
+ });
+}
+
+/** Each job requester's runners, with presence from their hub: jobs run on their requester's runners. */
+export async function presence(env: Env, actors: Iterable) {
+ return new Map(
await Promise.all(
- actors.map(
+ [...new Set(actors)].map(
async (actor) =>
[actor, await store.listRunners(env.DB, actor, new Set(await hub(env, actor).online()))] as const,
),
),
);
- return { projects, items, runners };
}
/** Record that the maintainer saw an item as it is now. */
diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx
index 007f441d..053517cc 100644
--- a/cloudflare/src/inbox/page.tsx
+++ b/cloudflare/src/inbox/page.tsx
@@ -3,6 +3,7 @@ import { type Prompt, parsePrompt } from "../jobs/prompt";
import { type Done, PUBLICATION_REPLY, inboxURL } from "../jobs/routes";
import { Marker, Notifications } from "../layout";
import { projectURL } from "../projects";
+import { type Card, PHASES, ago } from "./activity";
import type { Item, ProjectView, QuestionView, Runners } from "./model";
// The decision inbox, phone first: one active decision thread, then what is
@@ -21,6 +22,9 @@ export type InboxProps = {
working: Item[];
skipped: Item[];
runners: Map;
+ /** What each agent job is doing, beside the decisions. */
+ cards: Card[];
+ now: number;
/** The VAPID public key, or null where notifications are not configured. */
notifications: string | null;
};
@@ -170,7 +174,7 @@ function Approval(props: { item: Item }) {
);
}
-function jobsURL(item: Item) {
+function jobsURL(item: Pick) {
return `${projectURL(item.project.owner, item.project.name)}/jobs`;
}
@@ -533,76 +537,162 @@ function Project(props: { view: ProjectView }) {
);
}
+const workflows = { plan: "Planning", start: "Implementing", iterate: "Iterating", bootstrap: "Adding a devShell" };
+
+/**
+ * One agent job: its target, state, runner and account, and the agent's latest
+ * summary, as escaped text. Raw output loads only when a maintainer opens its
+ * disclosure, and the server rechecks their authority on every read.
+ */
+function AgentCard(props: { card: Card; runners: Runners; user: string; now: number }) {
+ const { card, now } = props;
+ const { job, item } = card;
+ // Another maintainer's answer waits on the requester's approval, not on this viewer.
+ const label = card.phase === "waiting" && item?.need === null ? "Awaiting approval" : PHASES[card.phase];
+ const status = item && progress(item, props.runners, props.user);
+ const when = ago(now, card.at);
+ const output = `${jobsURL(card)}/${job.id}/output`;
+ return (
+
+ No enrolled projects yet. Enroll a project, or open one a
+ co-maintainer enrolled, to get its issues, pull requests, and ready quests here.
+
+ )}
+
+ Lists enrolled projects you maintain, once you open each one's setup page. Runners and accounts
+ are set up with quest runner; your runners shows them and
+ the order jobs use your accounts in.
- Lists enrolled projects you maintain, once you open each one's setup page. Runners and accounts are
- set up with quest runner; your runners shows them and the order
- jobs use your accounts in.
-