toolroll 0.9.101 → 0.9.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,29 +1,44 @@
1
1
  import { browserWorkActionHref } from "../browser-crew.js";
2
- import { HOME_GROUP_LABEL, homeGroupOf } from "../home-groups.js";
2
+ import { NO_REASON_RECORDED } from "../needs-you.js";
3
3
  import { projectName } from "../project.js";
4
- import { workIndexPage } from "../work-index.js";
5
- import { relativeAge } from "./chrome.js";
4
+ import { WORK_ASK_GROUPS, workIndexPage } from "../work-index.js";
5
+ import { oneLineOf, relativeAge } from "./chrome.js";
6
6
  import { taskHref } from "./http.js";
7
- const TONE = { attention: "needs", ready: "needs", live: "live", done: "done", problem: "failed", muted: "", neutral: "" };
8
- const ASK_WORD = { decide: "Decision", review: "Result", unblock: "Blocked" };
9
- /** How many waiting rows Home lists; the rest open in Tasks. */
7
+ /** How many waiting rows Home lists; each group says how many more open in Tasks. */
10
8
  export const HOME_NEEDS_SHOWN = 30;
11
9
  const HOME_RUNNING_SHOWN = 8;
12
- /** How many rows finished in the last 7 days Home lists; the rest open in Tasks. */
13
- const HOME_DONE_SHOWN = 8;
14
- /** "7 need you across 2 projects." — the sentence and the figures read the same totals. */
15
- export function homeSentence(needs, perProject, scope, any) {
10
+ /** How many rows finished today Home lists; its figure counts them all. */
11
+ export const HOME_DONE_SHOWN = 8;
12
+ /** About one line of the lead's last message. */
13
+ const LEAD_LINE_LIMIT = 160;
14
+ /** Each group's heading, in the order Home shows them. */
15
+ export const HOME_ASK_LABEL = {
16
+ approve: "Approve a plan", answer: "Answer a question", wrong: "Something went wrong", review: "Review results", other: "Needs you",
17
+ };
18
+ /** "19 tasks need you": the heading, said once, from the same total as the rail's count. */
19
+ export function homeSentence(needs, any) {
16
20
  if (!any)
17
- return "No tasks yet.";
21
+ return "No tasks yet";
18
22
  if (needs === 0)
19
- return scope === null ? "Nothing needs you." : `Nothing needs you in ${scope}.`;
20
- const verb = needs === 1 ? "needs" : "need";
21
- if (scope !== null)
22
- return `${needs} ${verb} you in ${scope}.`;
23
- const waiting = perProject.filter(one => one.needs > 0);
24
- return waiting.length === 1 ? `${needs} ${verb} you in ${waiting[0].name}.` : `${needs} ${verb} you across ${waiting.length} projects.`;
23
+ return "Nothing needs you";
24
+ return needs === 1 ? "1 task needs you" : `${needs} tasks need you`;
25
25
  }
26
- function rowOf(store, item, now, multi, canAnswer, closedDoor) {
26
+ /** The lead's words as one plain line: no markdown marks, no line breaks, cut to about one line. */
27
+ export function leadLineOf(text) {
28
+ const plain = text
29
+ .replace(/```[\s\S]*?```/g, " ")
30
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
31
+ .replace(/`([^`]*)`/g, "$1")
32
+ .replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/gm, "")
33
+ .replace(/(\*\*|__|~~)(.*?)\1/g, "$2")
34
+ .replace(/(^|[\s(])[*_]([^*_\n]+)[*_](?=[\s).,!?:;]|$)/g, "$1$2");
35
+ return oneLineOf(plain, LEAD_LINE_LIMIT);
36
+ }
37
+ /** Midnight today on this computer's clock: "finished today" starts here. */
38
+ export function startOfToday(now) {
39
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate());
40
+ }
41
+ function rowOf(store, item, now, multi, canAnswer, closedDoor, state) {
27
42
  const action = item.primaryAction;
28
43
  const href = action?.code === "open-result" && action.target.runId != null
29
44
  ? `/review?result=${encodeURIComponent(action.target.taskId)}&run=${action.target.runId}` : browserWorkActionHref(item);
@@ -31,87 +46,87 @@ function rowOf(store, item, now, multi, canAnswer, closedDoor) {
31
46
  const decision = decisionId === null ? null : store.getDecision(decisionId);
32
47
  const open = decision !== null && decision.state === "open" && decision.supersededReason === null;
33
48
  // An irreversible option is armed behind a confirmation on its own page; Home answers only what one click may.
34
- const question = open && canAnswer && decision.options.every(option => option.reversible) ? {
49
+ const question = state === "needs" && open && canAnswer && decision.options.every(option => option.reversible) ? {
35
50
  id: decision.id, text: decision.question, recap: decision.recap,
36
51
  options: decision.options.map(option => ({ id: option.id, label: option.label, consequence: option.consequence, recommended: option.id === decision.recommendation })),
37
52
  } : null;
38
- const waits = item.status.views.includes("needs-you");
39
- // The specific ask, never the group's own words again: "Needs you" under Unblock reads "Blocked".
40
- const word = waits ? (open ? "Question" : item.chip ?? (item.status.label === "Needs you" && item.ask !== null ? ASK_WORD[item.ask] : item.status.label)) : item.status.label;
41
- const tone = waits ? "ask" : TONE[item.status.tone];
42
- const line = waits
43
- ? (open ? decision.question : item.status.detail !== item.status.label ? item.status.detail : null)
44
- : item.progress ?? (item.status.detail !== item.status.label ? item.status.detail : null);
53
+ const ask = state === "needs" ? item.askGroup ?? "other" : null;
54
+ const said = item.status.detail !== item.status.label ? item.status.detail : null;
55
+ const line = state === "needs" && open ? decision.question : item.progress ?? said;
56
+ // Only a row where something went wrong says why, in one short line.
57
+ // With no reason on record, the row says what happened (its status) instead of a sentence about the missing reason.
58
+ const reason = ask === "wrong" ? oneLineOf((said !== null && said !== NO_REASON_RECORDED ? said : item.status.label).replace(/[.\s]+$/, ""), 120) : null;
45
59
  // A plan that can't take a yes never offers one: the reason, and the one act that opens it, as the task page says.
46
- const door = action?.code === "approve-scope" ? closedDoor(action.target.taskId) : null;
60
+ const door = state === "needs" && action?.code === "approve-scope" ? closedDoor(action.target.taskId) : null;
47
61
  return {
48
- id: item.rootId, title: item.title, href: taskHref(item.rootId),
62
+ id: item.rootId, title: item.title, href: taskHref(item.rootId), state, ask, reason,
49
63
  project: multi || item.repo === null ? (item.repo === null ? "Unplaced" : projectName(item.repo)) : null,
50
- age: relativeAge(item.updatedAt, now), chip: { word, tone }, line: door === null ? line : door.title,
64
+ age: relativeAge(item.updatedAt, now), line: door === null ? line : door.title,
51
65
  action: door !== null ? { label: door.road === "agents" ? "Change the agents" : "Edit the plan", href: `${taskHref(action.target.taskId)}#${door.road === "agents" ? "agents" : "scope"}` }
52
66
  : href === null || action === null ? null : { label: action.label, href }, question,
53
67
  };
54
68
  }
55
- /** Home's view for one person and scope, from four reads of the work index (what waits on you, what runs, what waits on
56
- * Toolroll, what was done), grouped by the one rule every surface shares (home-groups.ts). */
69
+ /** Home's view for one person and scope, from four reads of the work index: what waits on you (with exact per-ask
70
+ * totals), what runs, what waits its turn, and what was completed today (filtered before its limit). */
57
71
  export function homeView(facts) {
58
72
  const { store, now, access, project } = facts;
59
- const read = (view, limit) => workIndexPage(store, now, access, { view, limit, project, ...(facts.evidenceRoot === undefined ? {} : { root: facts.evidenceRoot }) });
73
+ const read = (view, limit, completedSince) => workIndexPage(store, now, access, { view, limit, project, ...(completedSince === undefined ? {} : { completedSince }),
74
+ ...(facts.evidenceRoot === undefined ? {} : { root: facts.evidenceRoot }) });
60
75
  const needs = read("needs-you", HOME_NEEDS_SHOWN);
61
76
  const running = read("running", HOME_RUNNING_SHOWN);
62
77
  // Queued: what waits its turn, or what Toolroll or another task moves on, with no person asked.
63
78
  const waiting = read("queued", HOME_RUNNING_SHOWN);
64
- // Done: the Completed list is ordered by completion time, newest first, so its first rows are the newest finished;
65
- // only those finished in the last 7 days are listed, and that filter runs before the cap.
66
- const completed = read("completed", HOME_DONE_SHOWN);
79
+ // Done today: the Completed list from midnight on, filtered in the read itself, so the figure and the rows are
80
+ // what finished today however many there are.
81
+ const today = read("completed", HOME_DONE_SHOWN, startOfToday(now).toISOString());
67
82
  const totals = needs.totals;
68
- // Queued: the Tasks list's own Queued count (the same predicate its view=queued filter lists).
69
- const queued = totals.queued;
70
83
  // Every project is said by name (scope=all): /tasks alone would narrow to the session's open project.
71
84
  const scopeQuery = project === null ? "scope=all&" : `project=${encodeURIComponent(project)}&`;
72
- // Each figure keeps the name of the list it opens; Done is the whole Completed list, not just this week.
85
+ // Each figure keeps the list it opens.
73
86
  const counts = [
74
- { key: "needs", label: HOME_GROUP_LABEL.you, value: totals["needs-you"], href: `/tasks?${scopeQuery}view=needs-you` },
87
+ { key: "needs", label: "Needs you", value: totals["needs-you"], href: `/tasks?${scopeQuery}view=needs-you` },
75
88
  { key: "running", label: "Running", value: totals.running, href: `/tasks?${scopeQuery}view=running` },
76
- { key: "queued", label: "Queued", value: queued, href: `/tasks?${scopeQuery}view=queued` },
77
- { key: "finished", label: HOME_GROUP_LABEL.done, value: totals.completed, href: `/tasks?${scopeQuery}view=completed` },
89
+ { key: "queued", label: "Queued", value: totals.queued, href: `/tasks?${scopeQuery}view=queued` },
90
+ { key: "finished", label: "Finished", value: totals.completed, href: `/tasks?${scopeQuery}view=completed` },
78
91
  ];
79
- // One row per family, in the first group that claims it.
92
+ // One row per family, in the first list that claims it.
80
93
  const seen = new Set();
81
- const take = (items, group) => items.filter(one => {
82
- if (homeGroupOf(one) !== group || seen.has(one.rootId))
94
+ const take = (items, view) => items.filter(one => {
95
+ if (!one.status.views.includes(view) || seen.has(one.rootId))
83
96
  return false;
84
97
  seen.add(one.rootId);
85
98
  return true;
86
99
  });
87
- // Waiting on you, in today's order of what it asks: decide (with any row whose ask the index couldn't name, which
88
- // still waits on this person and is never lost), then review, then unblock. Each row's chip names its ask.
89
- const yours = take(["decide", null, "review", "unblock"].flatMap(ask => needs.items.filter(one => one.ask === ask)), "you");
90
- // Working: what runs, then what is queued (waits its turn, or on Toolroll or another task), each read with its own bound.
91
- const working = [...take(running.items, "working"), ...take(waiting.items, "working")];
92
- const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
93
- const done = take(completed.items.filter(one => (one.completion?.at ?? one.updatedAt) >= weekAgo), "done").slice(0, HOME_DONE_SHOWN);
94
- const multi = project === null && new Set([...yours, ...working, ...done].map(one => one.repo ?? "")).size > 1;
95
- const groups = [];
96
- for (const [key, items] of [["you", yours], ["working", working], ["done", done]]) {
97
- if (items.length > 0)
98
- groups.push({ key, label: HOME_GROUP_LABEL[key], rows: items.map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor)) });
99
- }
100
- const perProject = needs.projects.map(one => ({ name: one.repo === null ? "Unplaced" : projectName(one.repo), needs: one.totals["needs-you"] }));
100
+ const yours = take(needs.items, "needs-you");
101
+ const live = take(running.items, "running");
102
+ const queued = take(waiting.items, "queued");
103
+ const done = take(today.items, "completed");
104
+ const multi = project === null && new Set([...yours, ...live, ...queued, ...done].map(one => one.repo ?? "")).size > 1;
105
+ const row = (item, state) => rowOf(store, item, now, multi, facts.canAnswer, facts.closedDoor, state);
106
+ const rows = yours.map(one => row(one, "needs"));
107
+ // Each group's count is the work index's exact total for that ask, over every row in view, not only those listed.
108
+ const groups = WORK_ASK_GROUPS.flatMap(key => {
109
+ const count = needs.asks[key];
110
+ const listed = rows.filter(one => one.ask === key);
111
+ return count === 0 && listed.length === 0 ? [] : [{ key, label: HOME_ASK_LABEL[key], count: Math.max(count, listed.length), rows: listed }];
112
+ });
101
113
  const any = totals.all > 0 || totals.completed > 0 || (totals.archived ?? 0) > 0;
102
- const shown = yours.length;
114
+ const lead = facts.chat ? facts.leadMessage ?? null : null;
115
+ const leadText = lead === null ? "" : leadLineOf(lead.text);
103
116
  return {
104
117
  kind: "home",
105
- say: homeSentence(totals["needs-you"], perProject, project === null ? null : projectName(project), any),
118
+ say: homeSentence(totals["needs-you"], any),
106
119
  project,
107
120
  scopes: facts.projects.length < 2 ? null : [
108
121
  { key: "", label: "All projects", href: "/", active: project === null },
109
122
  ...facts.projects.map(one => ({ key: one.path, label: one.name, href: `/?project=${encodeURIComponent(one.path)}`, active: one.path === project })),
110
123
  ],
111
124
  counts, groups,
112
- more: totals["needs-you"] > shown ? { count: totals["needs-you"] - shown, href: counts[0].href } : null,
125
+ working: [...live.map(one => row(one, "running")), ...queued.map(one => row(one, "queued"))],
126
+ finishedToday: { count: Math.max(today.completedSince, done.length), rows: done.map(one => row(one, "done")), href: counts[3].href },
113
127
  newTask: "/tasks/new",
114
- ask: facts.chat ? { project, placeholder: project === null ? "Ask the lead about your projects…" : `Ask the lead about ${projectName(project)}…` } : null,
115
- empty: any ? null : { title: "No tasks yet.", text: "Describe a change in a sentence; an agent writes a plan for you to approve." },
128
+ ask: facts.chat ? { project, placeholder: "Message the lead" } : null,
129
+ lastLead: lead === null || leadText === "" ? null : { text: leadText, age: relativeAge(lead.at, now), href: "/chat" },
130
+ empty: any ? null : { title: "No tasks yet", text: "Describe a change in a sentence; an agent writes a plan for you to approve." },
116
131
  };
117
132
  }
@@ -329,3 +329,9 @@ export declare function passwordSetNext(raw: string | null | undefined): string;
329
329
  * the console sends no Referer to name the page the form was on. Checked by passwordSetNext; else home.
330
330
  */
331
331
  export declare function refusalNext(method: string, url: URL): string;
332
+ /**
333
+ * Whether an act asked for its answer in place (live-pages-and-home.md B2.3): `x-toolroll-act: in-place` turns the
334
+ * answer into a small JSON outcome (`{ outcome, words }`) instead of a page or a redirect. It chooses only the answer's
335
+ * shape: a handler reads it after every proof and authorisation decision is made, and no decision ever reads it.
336
+ */
337
+ export declare function answersInPlace(request: IncomingMessage | null | undefined): boolean;
@@ -315,3 +315,11 @@ export function refusalNext(method, url) {
315
315
  }
316
316
  return "/";
317
317
  }
318
+ /**
319
+ * Whether an act asked for its answer in place (live-pages-and-home.md B2.3): `x-toolroll-act: in-place` turns the
320
+ * answer into a small JSON outcome (`{ outcome, words }`) instead of a page or a redirect. It chooses only the answer's
321
+ * shape: a handler reads it after every proof and authorisation decision is made, and no decision ever reads it.
322
+ */
323
+ export function answersInPlace(request) {
324
+ return request?.headers["x-toolroll-act"] === "in-place";
325
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Which pages may change in place, and which may be read ahead (docs/plans/live-pages-and-home.md A1.1, A1.4).
3
+ *
4
+ * Both are explicit allowlists of route-table ids (route-table.ts): nothing is eligible by default, so a new route
5
+ * loads as its own document until it is added here on purpose. The welcome and first-run pages, the terminal, sign-in,
6
+ * the release sheet and every page not named below are never eligible. A page on the in-place list is still a document
7
+ * load when it is sensitive (a password field) or carries a page script (serve.ts sendScreen decides `inPlace`).
8
+ */
9
+ /** Home, the Tasks list, a task page, Projects, Agents (the list), Search and the Settings pages. */
10
+ export declare const IN_PLACE_ROUTES: ReadonlySet<string>;
11
+ /** What may be read ahead with `x-toolroll-read: prefetch`: Home, the Tasks list, Projects, Agents (the list, never an
12
+ * opened agent), Search, a task page. Everything else answers 204. */
13
+ export declare const PREFETCH_ROUTES: ReadonlySet<string>;
14
+ export declare function inPlaceRoute(id: string | undefined, url: URL): boolean;
15
+ export declare function prefetchRoute(id: string | undefined, url: URL): boolean;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Which pages may change in place, and which may be read ahead (docs/plans/live-pages-and-home.md A1.1, A1.4).
3
+ *
4
+ * Both are explicit allowlists of route-table ids (route-table.ts): nothing is eligible by default, so a new route
5
+ * loads as its own document until it is added here on purpose. The welcome and first-run pages, the terminal, sign-in,
6
+ * the release sheet and every page not named below are never eligible. A page on the in-place list is still a document
7
+ * load when it is sensitive (a password field) or carries a page script (serve.ts sendScreen decides `inPlace`).
8
+ */
9
+ /** The Settings pages (route-table.ts): the overview, its groups and each section page. Not the Slack manifest file. */
10
+ const SETTINGS_PAGES = [
11
+ "settings.page",
12
+ ...["general", "agents", "alerts", "projects", "access", "system"].map(one => `settings.group-${one}`),
13
+ ...["models", "tools", "project", "approval", "policy", "sessions", "integrations", "monitoring", "updates", "retention", "storage",
14
+ "pull-requests", "checks", "backups", "data", "sign-in", "lead", "teams", "discord", "slack",
15
+ "skills", "flows", "knowledge", "learning", "spend", "passwordless"].map(one => `settings.${one}`),
16
+ ];
17
+ /** Home, the Tasks list, a task page, Projects, Agents (the list), Search and the Settings pages. */
18
+ export const IN_PLACE_ROUTES = new Set(["home", "tasks", "task.page", "projects.page", "agents", "search", ...SETTINGS_PAGES]);
19
+ /** What may be read ahead with `x-toolroll-read: prefetch`: Home, the Tasks list, Projects, Agents (the list, never an
20
+ * opened agent), Search, a task page. Everything else answers 204. */
21
+ export const PREFETCH_ROUTES = new Set(["home", "tasks", "projects.page", "agents", "search", "task.page"]);
22
+ /** The Agents view's Live grid shares the `agents` row (`/agents?view=live`); it only watches, and is never in place. */
23
+ const liveGrid = (id, url) => id === "agents" && url.searchParams.get("view") === "live";
24
+ export function inPlaceRoute(id, url) {
25
+ return id !== undefined && IN_PLACE_ROUTES.has(id) && !liveGrid(id, url);
26
+ }
27
+ /** An opened agent shares the `agents` row too (`/agents?run=`): opening one marks it seen or watched, which is an
28
+ * act, so its page is never read ahead (the browser never shows it before the person's own read). */
29
+ const openedAgent = (id, url) => id === "agents" && url.searchParams.has("run");
30
+ export function prefetchRoute(id, url) {
31
+ return id !== undefined && PREFETCH_ROUTES.has(id) && !liveGrid(id, url) && !openedAgent(id, url);
32
+ }
@@ -72,7 +72,7 @@ import { rereadBrowser } from "./live.js";
72
72
  import { homeView } from "./home.js";
73
73
  import { workIndexPage } from "../work-index.js";
74
74
  export function createPagesHandlers(runtime) {
75
- const { identities, clock, lastChecks, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession, firstRunStepsNow } = runtime;
75
+ const { identities, clock, lastChecks, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession, firstRunStepsNow, chatEnablement, leadPrincipal, leadConversationRows } = runtime;
76
76
  // The filesystem browser (operator request): pick a project folder by
77
77
  // looking, not typing. CONFINED to exactly what opening allows —
78
78
  // explicit --repo mode has an enumerable list and gets no browser at
@@ -203,7 +203,9 @@ export function createPagesHandlers(runtime) {
203
203
  const door = consentDoorOf(scope, runtime.routeViewOf(taskId, store.lookupRef(taskId), scope, now, who));
204
204
  return door.open ? null : door;
205
205
  };
206
- const view = { ...homeView({ store, now, access: workAccess(), evidenceRoot, project, projects, chat: chrome.chat === true, canAnswer: who.role === "approver", closedDoor }),
206
+ const chat = chrome.chat === true;
207
+ const view = { ...homeView({ store, now, access: workAccess(), evidenceRoot, project, projects, chat, canAnswer: who.role === "approver", closedDoor,
208
+ leadMessage: chat ? leadLastMessage(who, now) : null }),
207
209
  // Until the first result: the welcome's three steps stay one link away.
208
210
  setup: firstRunStepsNow(now) !== null ? "/welcome" : null };
209
211
  return sendScreen(response, 200, screen("Home", html `<div data-kit-page><h1>Home</h1><p>${view.say}</p><p><a href="/tasks">Tasks</a></p></div>`, { chrome, workspace: { view } }));
@@ -1205,5 +1207,24 @@ export function createPagesHandlers(runtime) {
1205
1207
  "edge.font": fontAsset,
1206
1208
  "edge.desktop-health": desktopHealth,
1207
1209
  });
1210
+ /** The lead's last message in this person's main thread, for Home's one line above the message box. The thread is
1211
+ * read only through the gate a docked conversation uses (serve.ts dockedConversation): an approver in a browser
1212
+ * session, chat enabled, the approver's standing re-proved now (leadPrincipal), their lead session under the current
1213
+ * ceiling and generation; and then through leadConversationRows, the very rows /chat renders, which keep only a thread
1214
+ * under this person's current ceiling. Null at any refusal, and when the lead has said nothing yet. */
1215
+ function leadLastMessage(who, now) {
1216
+ if (who.role !== "approver")
1217
+ return null;
1218
+ const enabled = chatEnablement();
1219
+ const principal = enabled.ok ? leadPrincipal(who) : null;
1220
+ if (!enabled.ok || principal === null)
1221
+ return null;
1222
+ const found = store.activeLeadSession(who.name);
1223
+ const notStarted = (found === null || found.ceilingDigest !== principal.ceilingDigest) && enabled.billing === "subscription";
1224
+ if (!notStarted && (found === null || found.ceilingDigest !== principal.ceilingDigest || found.approverGeneration !== principal.generation))
1225
+ return null;
1226
+ const said = leadConversationRows(who, principal, null, now, null).messages.filter(one => one.role === "assistant" && one.pending !== true).at(-1);
1227
+ return said === undefined ? null : { text: said.text, at: said.createdAt };
1228
+ }
1208
1229
  return { registrations, projectsScreen };
1209
1230
  }
@@ -2597,7 +2597,7 @@ export function taskBodyParts(data) {
2597
2597
  { key: "fast-grown", html: data.fastGrown == null || data.csrf === "" ? "" : htmlString(html `<div class="card"><p>${data.fastGrown}</p>${postForm(`${taskHref(task.id)}/keep-grown`, btn("Approve it", { variant: "primary", type: "submit" }))}</div>`) },
2598
2598
  { key: "pull-request", html: fragmentString(pullRequestCard) },
2599
2599
  { key: "needs-scope", html: fragmentString(needsScopeCard) },
2600
- { key: "mirror", html: fragmentString(mirrorCard) }, { key: "attempt", html: fragmentString(attemptPanel) },
2600
+ { key: "mirror", html: fragmentString(mirrorCard) }, { key: "attempt", html: fragmentString(attemptPanel), ...(liveRunId === null ? {} : { instance: String(liveRunId) }) },
2601
2601
  ].filter(one => one.html !== ""),
2602
2602
  // Open questions are a lead block (key "questions"), so they sit in the step that waits.
2603
2603
  questions: "",
@@ -2672,13 +2672,22 @@ export function taskPage(chrome, data) {
2672
2672
  }
2673
2673
  const liveRunId = data.liveRunId ?? null;
2674
2674
  const liveRun = liveRunId === null ? undefined : data.runs.find(one => one.id === liveRunId);
2675
+ const answers = data.csrf !== "" && data.decisions.some(one => one.state === "open" || one.state === "expired");
2675
2676
  const script = (liveRun === undefined ? "" : regionScript("check-progress", "check", `/r/${liveRun.id}`)) +
2676
2677
  (liveRun !== undefined && data.peekable === true ? regionScript("run-peek", "peek", `/r/${liveRun.id}`) : "") +
2677
- (data.csrf !== "" && data.decisions.some(one => one.state === "open" || one.state === "expired") ? decisionAnswerScript() : "");
2678
+ (answers ? decisionAnswerScript() : "");
2679
+ // The same three, named for the app (task-page-in-app.md): it runs its own version of each where it can.
2680
+ const app = [];
2681
+ if (liveRun !== undefined)
2682
+ app.push({ kind: "region", region: "check-progress", run: liveRun.id });
2683
+ if (liveRun !== undefined && data.peekable === true)
2684
+ app.push({ kind: "region", region: "run-peek", run: liveRun.id });
2685
+ if (answers)
2686
+ app.push({ kind: "answer" });
2678
2687
  return screen(`task \u00b7 ${data.task.id}`, first.html, {
2679
2688
  chrome,
2680
2689
  workspace: { view: first.view },
2681
- ...(script === "" ? {} : { functional: { script, fetches: true } }),
2690
+ ...(script === "" ? {} : { functional: { script, fetches: true, app } }),
2682
2691
  });
2683
2692
  }
2684
2693
  /** One accessible two-choice control everywhere permissions are selected.
@@ -2780,6 +2789,8 @@ export function approveKitOf(input, freshSince) {
2780
2789
  ...(input.localOwner?.eligible === true ? { termsWords: "No password needed on this computer." } : {}),
2781
2790
  // What it signs: the plan sheet (the same parts as the chat's sheet, re-ordered), then the in-place editor.
2782
2791
  planHtml: htmlString(joinHtml([parts.sheet, parts.editor ?? "", parts.editorForm])),
2792
+ // The plan sheet alone, with no editor and no form: what Home shows beside its one-click Approve (B2.1).
2793
+ sheetHtml: htmlString(parts.sheet),
2783
2794
  };
2784
2795
  }
2785
2796
  /** How an approved plan was approved, for the status box's terms line: by whom and how, or the rule that approved it. */
@@ -22,6 +22,13 @@ export type RequestFacts = {
22
22
  timeline?: import("../latency-timeline.js").Timeline;
23
23
  browser?: boolean;
24
24
  workspaceRead?: boolean;
25
+ /** Why a `?format=workspace` GET was made (`x-toolroll-read`, readPurpose below): bookkeeping only, never authority. */
26
+ readPurpose?: ReadPurpose;
27
+ /** A workspace read from an app that runs page work itself (`x-toolroll-work`, task-page-in-app.md §2): it picks
28
+ * which of two forms of the same page is sent, never what may be read. */
29
+ appWork?: boolean;
30
+ /** The route table row that matched this request (route-table.ts ids), for the in-place allowlists (serve.ts). */
31
+ routeId?: string;
25
32
  /** Receipt reconciliation, task and result reads (`request`, `task`, `result`): work is read afresh, never cached. */
26
33
  freshWork?: boolean;
27
34
  /** A workspace re-read's If-None-Match: the content validator it holds from its last read. */
@@ -62,3 +69,10 @@ export type RequestFacts = {
62
69
  export declare const requestContext: AsyncLocalStorage<RequestFacts>;
63
70
  /** Run a render outside a live request (a test, a background render) as if a browser session with this token asked. */
64
71
  export declare function withFormToken<T>(csrf: string, render: () => T): T;
72
+ /**
73
+ * What a workspace read is for (docs/plans/live-pages-and-home.md A1.4), from its `x-toolroll-read` header. It is a hint
74
+ * about bookkeeping (does the read extend the session, does it mint an approval nonce), never proof that a person
75
+ * clicked, and no authorisation decision reads it. Absent, `poll` or anything unknown is today's re-read: `poll`.
76
+ */
77
+ export type ReadPurpose = "poll" | "navigate" | "prefetch";
78
+ export declare function readPurpose(header: string | string[] | undefined): ReadPurpose;
@@ -16,3 +16,6 @@ export function withFormToken(csrf, render) {
16
16
  const facts = requestContext.getStore();
17
17
  return requestContext.run({ ...(facts ?? { returnTo: "/" }), csrf }, render);
18
18
  }
19
+ export function readPurpose(header) {
20
+ return header === "navigate" || header === "prefetch" ? header : "poll";
21
+ }
@@ -1,3 +1,4 @@
1
+ import { requestContext } from "./request-context.js";
1
2
  import { heldFindingCount } from "../findings.js";
2
3
  import { familyInWebReach } from "../findings.js";
3
4
  import { existsSync } from "node:fs";
@@ -54,7 +55,7 @@ import { isRevisionFeedback } from "../result-review.js";
54
55
  import { buildReviewOf } from "../review-switch.js";
55
56
  import { isAlive as runnerAlive } from "../runner.js";
56
57
  import { approvalOf } from "../scope.js";
57
- import { oneLineOf, refuse, screen } from "./chrome.js";
58
+ import { oneLineOf, refuse, screen, withScript } from "./chrome.js";
58
59
  import { chatResultHref, reviewHref, taskChatHref, taskHref } from "./http.js";
59
60
  import { decisionsFor } from "./render-chat.js";
60
61
  import {} from "./render-pages.js";
@@ -817,7 +818,8 @@ export function createTaskViews(runtime) {
817
818
  const admittedFamily = runtime.familyOf(taskId);
818
819
  if (admittedFamily?.problem != null)
819
820
  return runtime.sendScreen(response, status, screen(admittedFamily.root.title, html `<h1>${admittedFamily.root.title}</h1><p>${admittedFamily.current.state}</p><p class="problem" data-history-problem>${admittedFamily.problem}</p>`, { chrome: runtime.chromeFor(admittedFamily.root.repo, "tasks") }));
820
- const data = taskViewData(taskId, who, problem);
821
+ // A read ahead (x-toolroll-read: prefetch, live-pages-and-home.md A1.4) mints nothing: its plan form says "unminted".
822
+ const data = taskViewData(taskId, who, problem, { mintNonce: requestContext.getStore()?.readPurpose !== "prefetch" });
821
823
  if (data === null)
822
824
  return refuse(response, who, 404, "no such task", "/tasks");
823
825
  const family = runtime.familyOf(taskId);
@@ -895,7 +897,8 @@ export function createTaskViews(runtime) {
895
897
  page.body = html `${page.body}<section class="task-result-tab" id="task-result" aria-label="Result">${extras.result.html}</section>`;
896
898
  // The result's own script, as its result page ships it (a password form there keeps it too: Request changes and
897
899
  // the line notes need it).
898
- page.functional = { script: `${page.functional?.script ?? ""}${reviewEvidenceScript()}${RESULT_REVIEW_SCRIPT}`, fetches: page.functional?.fetches ?? false };
900
+ // The app runs its own version (browser/work/result.ts), so a task with a result can open in place.
901
+ page.functional = withScript(page.functional, { script: `${reviewEvidenceScript()}${RESULT_REVIEW_SCRIPT}`, app: [{ kind: "result" }] });
899
902
  }
900
903
  if (docked !== null)
901
904
  page.workspace = { ...page.workspace, ...(oneTaskView === undefined ? {} : { view: oneTaskView }), conversation: docked, pageHtml: page.body };
@@ -1,6 +1,6 @@
1
1
  /** tasks handlers, moved without changing their route bodies. */
2
2
  import { type Html } from "../html.js";
3
- import { type ServerResponse } from "node:http";
3
+ import { type IncomingMessage, type ServerResponse } from "node:http";
4
4
  import { type Artifact, type Run, type Store } from "../store.js";
5
5
  import type { HandlerContext } from './handler-context.js';
6
6
  import type { ServerRuntime } from './runtime.js';
@@ -15,7 +15,7 @@ export declare function createTasksHandlers(runtime: ServerRuntime): {
15
15
  body: Html;
16
16
  retryAfter?: number;
17
17
  }>;
18
- taskMutation: (response: ServerResponse, who: Who, taskId: string, verb: string, posted: URLSearchParams, now: Date) => void;
18
+ taskMutation: (response: ServerResponse, who: Who, taskId: string, verb: string, posted: URLSearchParams, now: Date, request?: IncomingMessage | null) => void;
19
19
  queueRegionFor: (project: string | null, csrf: string, revision: number, now: Date) => Html;
20
20
  ciFailingFor: (runId: number | null, prNumber: number | null) => boolean;
21
21
  completedRowFor: (taskId: string, project: string | null) => CompletedWorkRow | null;
@@ -75,3 +75,9 @@ export declare function createReleaseSheetHandlers(runtime: ServerRuntime): {
75
75
  sheetScreen: (ctx: Pick<HandlerContext, "who" | "response" | "project">, taskId: string, problem: string | null, status: number) => void;
76
76
  approveFromSheet: (ctx: Pick<HandlerContext, "who" | "response" | "project">, taskId: string, cardDigest: string, token: string) => void;
77
77
  };
78
+ /** What a plan approval asked in place came to (live-pages-and-home.md B2.3): approved; a first yes on protected work
79
+ * recorded; approved but building again did not start; or refused, with nothing changed. */
80
+ export type ApprovalOutcome = "approved" | "vote" | "approved-not-rebuilt" | "refused";
81
+ /** The answer to an approval asked in place: its named outcome and the same words the page would show, as JSON. Written
82
+ * only after the approve route's proofs, by the caller that knows the outcome. */
83
+ export declare function answerApprovalInPlace(response: ServerResponse, outcome: ApprovalOutcome, words: string, status: number): void;