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.
@@ -9,6 +9,7 @@ export { browserCrewFromIndex, browserWorkActionHref } from './browser-crew.js';
9
9
  import type { Store } from './store.js';
10
10
  import type { WorkSummaryAccess } from './work-summary.js';
11
11
  import type { StatusTone, StepRow } from './workspace-ui.js';
12
+ import type { PageWork } from './page-work-contract.js';
12
13
  import type { Ask, AskChip } from './needs-you.js';
13
14
  import type { WorkIndexGroup } from './work-index.js';
14
15
  import type { AssignmentCard } from './assignment-ui.js';
@@ -486,9 +487,12 @@ export type BrowserTaskView = {
486
487
  }[];
487
488
  } | null;
488
489
  /** Server cards that may need a person now (stop/resume, scope prompt, plan, live attempt). */
490
+ /** `instance`: what the block is about now (the attempt card: its build). The page installs a block's HTML once;
491
+ * another instance replaces it. */
489
492
  lead: {
490
493
  key: string;
491
494
  html: string;
495
+ instance?: string;
492
496
  }[];
493
497
  questions: string;
494
498
  facts: BrowserTaskFact[];
@@ -549,6 +553,8 @@ export type BrowserTaskView = {
549
553
  /** When a remembered password stops counting (ISO); the page words it in the viewer's zone. */
550
554
  rememberedUntil: string | null;
551
555
  planHtml: string;
556
+ /** The plan sheet alone (no editor, no form): what Home shows beside its Approve (live-pages-and-home.md B2.1). */
557
+ sheetHtml?: string;
552
558
  /** The terms line in words, when the rule isn't one of the kit's (easy-start C5: a laptop's own owner). */
553
559
  termsWords?: string;
554
560
  } | null;
@@ -1613,6 +1619,15 @@ export type BrowserWorkspace = {
1613
1619
  user: string;
1614
1620
  csrf: string;
1615
1621
  sensitive: boolean;
1622
+ /** True when this page may replace another without a document load (docs/plans/live-pages-and-home.md A1.1): the server
1623
+ * sets it on every workspace it sends; absent (an older server, a fixture) is false. */
1624
+ inPlace?: boolean;
1625
+ /** What the app runs for this page in place of a page script (docs/plans/task-page-in-app.md): data, a closed list.
1626
+ * Present only when the app covers the page's whole script; the document then ships no page script string. */
1627
+ work?: readonly PageWork[];
1628
+ /** This document ships a page script string (set on a document load only). Its listeners live as long as the
1629
+ * document, so the app never changes pages in place from it, and never runs `work` in it. */
1630
+ scripted?: boolean;
1616
1631
  /** Passwordless is on for this signed-in browser: ceremony password fields are left out (docs/plans/passwordless.md). */
1617
1632
  passwordless?: boolean;
1618
1633
  /** The session's open project (its path), or null: the sidebar's project picker names it. */
@@ -1688,13 +1703,14 @@ export type BrowserWorkspace = {
1688
1703
  command: string;
1689
1704
  };
1690
1705
  };
1691
- /** Home ("/"): one sentence that answers "what needs me?", four counts that agree with the Tasks filters they open,
1692
- * the work grouped as Waiting on you, Working and Done, and the selected item answered in place. */
1706
+ /** Home ("/", docs/plans/live-pages-and-home.md B1): the heading said once, one quiet line for what runs and waits, what
1707
+ * needs this person grouped by what is asked (each group with its exact count), the selected one open beside the list,
1708
+ * and the lead's last message above the message box. With nothing waiting: what runs and what finished today. */
1693
1709
  export type BrowserHomeView = {
1694
1710
  kind: 'home';
1695
- /** "7 need you across 2 projects.", derived from the same counts as the figures. */
1711
+ /** "19 tasks need you", "Nothing needs you", read from the same total as the rail's count. */
1696
1712
  say: string;
1697
- /** The project in view (a path) or every project (null), and the scope links (null with one project). */
1713
+ /** The project in view (a path) or every project (null), and the project filter's choices (null with one project). */
1698
1714
  project: string | null;
1699
1715
  scopes: {
1700
1716
  key: string;
@@ -1702,29 +1718,42 @@ export type BrowserHomeView = {
1702
1718
  href: string;
1703
1719
  active: boolean;
1704
1720
  }[] | null;
1721
+ /** The work index totals, each with the Tasks list it opens. */
1705
1722
  counts: {
1706
1723
  key: 'needs' | 'running' | 'queued' | 'finished';
1707
1724
  label: string;
1708
1725
  value: number;
1709
1726
  href: string;
1710
1727
  }[];
1711
- /** The work in three groups, in this order (home-groups.ts): Waiting on you, Working, Done (finished in the last 7 days). */
1728
+ /** What needs you, by what is asked, in this order: approve, answer, wrong, review, other (anything none of the four
1729
+ * fits). `count` is the exact total for that ask, past the rows listed. */
1712
1730
  groups: {
1713
- key: 'you' | 'working' | 'done';
1731
+ key: BrowserHomeAsk;
1714
1732
  label: string;
1733
+ count: number;
1715
1734
  rows: BrowserHomeRow[];
1716
1735
  }[];
1717
- /** More waits on you than the list shows: open them all in Tasks. */
1718
- more: {
1736
+ /** What runs, then what waits its turn. */
1737
+ working: BrowserHomeRow[];
1738
+ /** What was completed today: the exact figure, the newest rows, and the list that has them all. */
1739
+ finishedToday: {
1719
1740
  count: number;
1741
+ rows: BrowserHomeRow[];
1720
1742
  href: string;
1721
- } | null;
1743
+ };
1722
1744
  newTask: string;
1723
1745
  /** The lead's message box, where chat is allowed for this person: a POST to /chat in this scope. */
1724
1746
  ask: {
1725
1747
  project: string | null;
1726
1748
  placeholder: string;
1727
1749
  } | null;
1750
+ /** The lead's last message in this person's main thread, as one plain line that opens the chat; null when there is
1751
+ * none or this person may not read that thread. */
1752
+ lastLead: {
1753
+ text: string;
1754
+ age: string;
1755
+ href: string;
1756
+ } | null;
1728
1757
  empty: {
1729
1758
  title: string;
1730
1759
  text: string;
@@ -1732,16 +1761,20 @@ export type BrowserHomeView = {
1732
1761
  /** While the first run is open (no first result yet): the welcome's address, linked as Finish setup. */
1733
1762
  setup?: string | null;
1734
1763
  };
1764
+ /** What a waiting row asks (work-index.ts WorkAskGroup). */
1765
+ export type BrowserHomeAsk = 'approve' | 'answer' | 'wrong' | 'review' | 'other';
1735
1766
  export type BrowserHomeRow = {
1736
1767
  id: string;
1737
1768
  title: string;
1738
1769
  href: string;
1739
1770
  project: string | null;
1740
1771
  age: string;
1741
- chip: {
1742
- word: string;
1743
- tone: '' | 'needs' | 'ask' | 'live' | 'done' | 'failed';
1744
- };
1772
+ /** Which list it is in: it needs you, it runs, it waits its turn, or it finished. */
1773
+ state: 'needs' | 'running' | 'queued' | 'done';
1774
+ /** What it asks of you (its group), or null when it doesn't wait on you. */
1775
+ ask: BrowserHomeAsk | null;
1776
+ /** Why, in one short line: only a row where something went wrong. */
1777
+ reason: string | null;
1745
1778
  /** One line: what it asks, or what the agent is doing now. */
1746
1779
  line: string | null;
1747
1780
  /** The one act that resolves it (a link), or null. */
@@ -1933,3 +1966,4 @@ export type MergeReadyView = {
1933
1966
  act: MergeReadyAct | null;
1934
1967
  }[];
1935
1968
  };
1969
+ export type { PageWork } from './page-work-contract.js';
@@ -97,11 +97,13 @@ export function browserProjectsOf(projects) {
97
97
  export function browserNavigationOf(path, project = null, needsYou = 0, tasksProject = project, needsYouLabel) {
98
98
  const pathname = path.split('?')[0].split('#')[0];
99
99
  const under = (...roots) => roots.some(root => pathname === root || pathname.startsWith(`${root}/`));
100
- // The rail (UI revamp, wave B): Home carries the one Needs-you count; the lead chat and an agent's question live
101
- // under Home, a task's pages under Tasks, every computer and terminal under Agents, every setting under Settings.
100
+ // The rail (UI revamp, wave B; Chat under Home, live-pages-and-home.md B1): Home carries the one Needs-you count and
101
+ // an agent's question lives under it; the lead chat is its own item; a task's pages under Tasks, every computer and
102
+ // terminal under Agents, every setting under Settings.
102
103
  return [
103
- { label: 'Home', href: '/', active: pathname === '/' || pathname === '/chat' || under('/d', '/activity', '/morning'),
104
+ { label: 'Home', href: '/', active: pathname === '/' || under('/d', '/activity', '/morning'),
104
105
  ...(needsYou > 0 ? { count: needsYou, ...(needsYouLabel === undefined ? {} : { countLabel: needsYouLabel }) } : {}) },
106
+ { label: 'Chat', href: '/chat', active: under('/chat') },
105
107
  { label: 'Tasks', href: `/tasks${tasksProject === null ? '' : `?project=${encodeURIComponent(tasksProject)}`}`,
106
108
  active: under('/tasks', '/t', '/r', '/review') },
107
109
  { label: 'Flows', href: '/flows', active: under('/flows', '/recipes', '/kits', '/routines') },
@@ -97,13 +97,13 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
97
97
  export declare function lockHeld(file: string): boolean;
98
98
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
99
99
  export declare function desktopUpdateStatus(stateDir: string): {
100
+ error?: never;
100
101
  active: boolean;
101
102
  phase: string;
102
103
  detail: string;
103
104
  running: boolean;
104
105
  canResume: boolean;
105
106
  canCancel: boolean;
106
- error?: never;
107
107
  automaticRecovery?: never;
108
108
  recoveryAttempts?: never;
109
109
  recoveryRepairs?: never;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * One behaviour the app runs for the page on screen instead of a page script string (docs/plans/task-page-in-app.md).
3
+ * `answer`: a question's inline answer (render-tasks.ts decisionAnswerScript). `region`: a server-drawn region kept
4
+ * current from a build's own fragment (chrome.ts regionScript), `/r/<run>?fragment=check|peek`. `result`: reviewing the
5
+ * result the page shows (result-review.ts RESULT_REVIEW_SCRIPT with render-results.ts reviewEvidenceScript). No code,
6
+ * selector or address travels here: the app runs only the kinds it knows.
7
+ */
8
+ export type PageWork = {
9
+ kind: "answer";
10
+ } | {
11
+ kind: "result";
12
+ } | {
13
+ kind: "region";
14
+ region: "check-progress" | "run-peek";
15
+ run: number;
16
+ };
17
+ /** The work the app and the server agree on. A workspace read says it with `x-toolroll-work`; a reader that does not
18
+ * say exactly this version (an older tab) gets the page as a document with its script string, as before. A hint, never
19
+ * authority. It moves whenever a kind is added: 2 added `result`. */
20
+ export declare const PAGE_WORK_VERSION = 2;
21
+ export declare const PAGE_WORK_HEADER = "x-toolroll-work";
@@ -0,0 +1,5 @@
1
+ /** The work the app and the server agree on. A workspace read says it with `x-toolroll-work`; a reader that does not
2
+ * say exactly this version (an older tab) gets the page as a document with its script string, as before. A hint, never
3
+ * authority. It moves whenever a kind is added: 2 added `result`. */
4
+ export const PAGE_WORK_VERSION = 2;
5
+ export const PAGE_WORK_HEADER = "x-toolroll-work";
@@ -22,18 +22,19 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
22
22
  export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
+ title?: never;
26
+ content?: never;
25
27
  history?: never;
26
28
  revision: number;
27
29
  stale: boolean;
28
30
  notice: string;
29
- title?: never;
30
- content?: never;
31
31
  instructions?: never;
32
32
  references?: never;
33
33
  decisions?: never;
34
34
  waiting?: never;
35
35
  } | {
36
- revision?: never;
36
+ title?: never;
37
+ content?: never;
37
38
  history?: never;
38
39
  stale?: never;
39
40
  decision: {
@@ -52,8 +53,7 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
52
53
  };
53
54
  afterwards?: string;
54
55
  notice: string;
55
- title?: never;
56
- content?: never;
56
+ revision?: never;
57
57
  instructions?: never;
58
58
  references?: never;
59
59
  decisions?: never;
@@ -70,9 +70,9 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
70
70
  decisions?: never;
71
71
  waiting?: never;
72
72
  } | {
73
- stale?: never;
74
73
  title?: never;
75
74
  content?: never;
75
+ stale?: never;
76
76
  revision: number;
77
77
  history: {
78
78
  revision: number;
@@ -68,6 +68,7 @@ export declare function deriveRunEfficiency(run: Run & {
68
68
  }, evidenceRoot: string, now: Date, commands?: boolean): RunEfficiency;
69
69
  /**
70
70
  * A finished run's How it ran: the saved one when it was derived by this version, else derived now and saved (once:
71
- * a row whose steps were complete or have expired is final). An unfinished run is derived but never saved.
71
+ * a row whose steps were complete or have expired is final). An unfinished run is derived but never saved, and
72
+ * nothing is saved when `save` is false (a page read ahead of its link writes nothing).
72
73
  */
73
- export declare function runEfficiencyOf(store: Store, evidenceRoot: string, runId: number, now: Date): RunEfficiency | null;
74
+ export declare function runEfficiencyOf(store: Store, evidenceRoot: string, runId: number, now: Date, save?: boolean): RunEfficiency | null;
@@ -199,9 +199,10 @@ export function deriveRunEfficiency(run, evidenceRoot, now, commands = false) {
199
199
  }
200
200
  /**
201
201
  * A finished run's How it ran: the saved one when it was derived by this version, else derived now and saved (once:
202
- * a row whose steps were complete or have expired is final). An unfinished run is derived but never saved.
202
+ * a row whose steps were complete or have expired is final). An unfinished run is derived but never saved, and
203
+ * nothing is saved when `save` is false (a page read ahead of its link writes nothing).
203
204
  */
204
- export function runEfficiencyOf(store, evidenceRoot, runId, now) {
205
+ export function runEfficiencyOf(store, evidenceRoot, runId, now, save = true) {
205
206
  const run = store.getRun(runId);
206
207
  if (run === null)
207
208
  return null;
@@ -227,7 +228,7 @@ export function runEfficiencyOf(store, evidenceRoot, runId, now) {
227
228
  const payload = JSON.stringify(derived);
228
229
  // A row too large for its column (it can't be: the counts are bounded) is shown, never saved.
229
230
  // Saving is a convenience: a store that can't take the row (read-only, busy) still shows what was derived.
230
- if (payload.length <= 16_384) {
231
+ if (save && payload.length <= 16_384) {
231
232
  try {
232
233
  store.handle.prepare(`INSERT INTO run_efficiency (run, parser_version, steps, steps_reason, payload_json, derived_at) VALUES (?, ?, ?, ?, ?, ?)
233
234
  ON CONFLICT(run) DO UPDATE SET parser_version = excluded.parser_version, steps = excluded.steps, steps_reason = excluded.steps_reason,
package/dist/serve.js CHANGED
@@ -27,6 +27,7 @@ const PROXY_HEADER = /^(forwarded|via|x-forwarded-.+|x-real-ip|x-client-ip|x-ori
27
27
  const BACKGROUND_CHECKS_MS = 30_000;
28
28
  const LOCAL_AGENTS_EVERY_MS = 2 * 60_000;
29
29
  import { browserCrewFromIndex, browserCrewOf, browserNavigationOf, browserProjectsOf, needsYouLabelOf } from './browser-workspace.js';
30
+ import { PAGE_WORK_HEADER, PAGE_WORK_VERSION } from './page-work-contract.js';
30
31
  import { sharedActionPayload } from './chat-actions.js';
31
32
  import { prepareCodingContext } from './coding-context.js';
32
33
  import { CodingWorkspace } from './coding-workspace.js';
@@ -155,7 +156,8 @@ import { createFlowsHandlers } from './server/flows.js';
155
156
  import { createPagesHandlers } from './server/pages.js';
156
157
  import { createPeopleHandlers } from './server/people-tokens.js';
157
158
  import { createSettingsHandlers } from './server/settings.js';
158
- import { requestContext } from "./server/request-context.js";
159
+ import { readPurpose, requestContext } from "./server/request-context.js";
160
+ import { inPlaceRoute, prefetchRoute } from "./server/in-place.js";
159
161
  import { asLocalOwner, localModeOn, localOwnerRefusal, localOwnerStandsNow } from "./local-owner.js";
160
162
  import { defaultAgentOf, fillDefaultRouting } from "./model-choices.js";
161
163
  import { chromeScript, DEMO_BANNER, DEMO_BANNER_SHORT, focusDocument, KBD_HELP, pinnedTheme, QUEUE_VIEW, refuse, screen, SENSITIVE_INPUT, shell, sidebarScript } from "./server/chrome.js";
@@ -878,9 +880,22 @@ export function createDecisionServer(options) {
878
880
  // Passive polls authenticate without extending the browser session.
879
881
  const fragmentName = url.searchParams.get("fragment");
880
882
  const workspaceRead = method === "GET" && url.searchParams.get('format') === 'workspace';
881
- const touch = !(method === "GET" && (edgeRoute?.domain === 'live' || workspaceRead || (fragmentName !== null && NO_TOUCH_FRAGMENTS.has(fragmentName)) || /^\/code\/[a-f0-9]{32}\/state$/.test(url.pathname) || url.pathname === '/term/state'
883
+ // A workspace read's purpose (live-pages-and-home.md A1.4): bookkeeping only. Read on workspace GETs alone; on any other
884
+ // request the header means nothing. No authorisation decision below reads it.
885
+ const purpose = workspaceRead ? readPurpose(request.headers['x-toolroll-read']) : 'poll';
886
+ // The reads that never extend the session, whatever their header says.
887
+ const noTouch = edgeRoute?.domain === 'live' || (fragmentName !== null && NO_TOUCH_FRAGMENTS.has(fragmentName)) || /^\/code\/[a-f0-9]{32}\/state$/.test(url.pathname) || url.pathname === '/term/state'
882
888
  // The Agents view's Live grid only watches: its polls never extend or write the session.
883
- || url.pathname === '/agents/live' || /^\/agents\/live\/[0-9]{1,15}$/.test(url.pathname) || (url.pathname === '/agents' && url.searchParams.get('view') === 'live')));
889
+ || url.pathname === '/agents/live' || /^\/agents\/live\/[0-9]{1,15}$/.test(url.pathname) || (url.pathname === '/agents' && url.searchParams.get('view') === 'live');
890
+ // A followed link (`navigate`) extends the session exactly as the HTML GET of the same address would; every other
891
+ // workspace read (a re-read, a prefetch) does not.
892
+ const touch = !(method === "GET" && (noTouch || (workspaceRead && purpose !== 'navigate')));
893
+ // A read ahead is answered only for a browser session, only for the prefetch allowlist (in-place.ts): anything else
894
+ // hears 204 before any sign-in bookkeeping (a token's last use, a sign-in charge) is written.
895
+ if (purpose === 'prefetch' && (request.headers.authorization !== undefined || !prefetchRoute(matchedRoute?.id, url))) {
896
+ request.resume();
897
+ return respond(response, 204, "text/plain; charset=utf-8", "");
898
+ }
884
899
  const refuseBudget = (admitted) => {
885
900
  if (admitted.ok)
886
901
  return false;
@@ -906,6 +921,11 @@ export function createDecisionServer(options) {
906
921
  }
907
922
  if (who?.via === "bearer" && refuseBudget(admitBearer(request, who)))
908
923
  return;
924
+ // A read ahead from anything but a signed-in browser session (signed out, a token): 204, never the sign-in page.
925
+ if (purpose === 'prefetch' && who?.via !== 'cookie') {
926
+ request.resume();
927
+ return respond(response, 204, "text/plain; charset=utf-8", "");
928
+ }
909
929
  const passwordProof = who?.via === 'cookie' ? { name: who.name, generation: who.session.generation } : null;
910
930
  const fresh = freshFactsOf(request, who, matchedRoute?.freshness === 'allowlist');
911
931
  if (edgeRoute?.domain === 'people')
@@ -940,6 +960,9 @@ export function createDecisionServer(options) {
940
960
  get fresh() { return freshFacts.getStore() != null && ssoNowAvailable(who.name); } } : undefined,
941
961
  refusal: (answer, status, body) => sendScreen(answer, status, screen(status === 404 ? "Not found" : "Request refused", body, { chrome: chromeFor(who.via === "cookie" ? who.session.project : null, "work") })),
942
962
  workspaceRead,
963
+ readPurpose: purpose,
964
+ ...(workspaceRead && request.headers[PAGE_WORK_HEADER] === String(PAGE_WORK_VERSION) ? { appWork: true } : {}),
965
+ ...(matchedRoute?.stage === 'console' ? { routeId: matchedRoute.id } : {}),
943
966
  freshWork: ["request", "task", "result"].some(name => url.searchParams.has(name)),
944
967
  ...(workspaceRead && typeof request.headers['if-none-match'] === 'string' ? { workspaceIfNoneMatch: request.headers['if-none-match'] } : {}),
945
968
  workspaceRequest: url.searchParams.get('request'),
@@ -952,7 +975,9 @@ export function createDecisionServer(options) {
952
975
  ? { localStepUp: localOwnerRefusal(false, method === "GET" ? passwordSetNext(pagePathOf(url)) : "/").href } : {}),
953
976
  reconnect: browserKeys.stale(who, method === "GET" ? safeReturn(pagePathOf(url)) : "/"),
954
977
  };
955
- response.once("finish", () => keepSlowRequest(store, timeline, matchedRoute?.id ?? "unmatched", response.statusCode, clock()));
978
+ // A read ahead writes nothing, not even a slow request's record.
979
+ if (purpose !== 'prefetch')
980
+ response.once("finish", () => keepSlowRequest(store, timeline, matchedRoute?.id ?? "unmatched", response.statusCode, clock()));
956
981
  if (method === "GET" || method === "POST")
957
982
  return freshFacts.run(fresh, () => provenPasswordAccount.run(passwordProof, () => requestContext.run(requestFacts, async () => {
958
983
  if (edgeRoute?.domain === 'live')
@@ -1669,8 +1694,29 @@ export function createDecisionServer(options) {
1669
1694
  }
1670
1695
  const conversation = extras.conversation ?? null;
1671
1696
  const request = requestFacts.workspaceRequest;
1697
+ // React owns the conversation and its refresh/draft lifecycle. Native
1698
+ // guarded forms keep their current behavior after React inserts them.
1699
+ // A page with its own view keeps its script; the chat page runs the result script.
1700
+ // The exact page script the document would run: the JSON arm and the document arm read this one value.
1701
+ const ownScript = conversation === null || extras.view;
1702
+ const pageScript = ownScript ? (s.functional?.script ?? '') : RESULT_REVIEW_SCRIPT;
1703
+ const routeInPlace = inPlaceRoute(requestFacts.routeId, path);
1704
+ // The app runs the page's behaviour itself (docs/plans/task-page-in-app.md §2) only when all of these hold: the
1705
+ // app covers the WHOLE script, no password field (a password page keeps its string path), an in-place route, and a
1706
+ // reader that can run it: a document load (its bundle is this server's, served no-store) or a workspace read that
1707
+ // says so. Anything else is the page exactly as before: the string, no work, a document.
1708
+ const covered = ownScript ? s.functional?.app : undefined;
1709
+ const work = pageScript !== '' && covered !== undefined && covered.length > 0 && !sensitive && routeInPlace
1710
+ && (!requestFacts.workspaceRead || requestFacts.appWork === true) ? covered : null;
1711
+ const functional = work === null ? pageScript : '';
1712
+ // May replace another page without a document load (live-pages-and-home.md A1.1): an allowlisted route, no password
1713
+ // field, no page script (a page script runs once per document, so its page always loads as one).
1714
+ const inPlace = routeInPlace && !sensitive && functional === '';
1672
1715
  const workspace = {
1673
- version: 1, path: currentPath, title: s.title, user: requestFacts.actor ?? '', csrf: requestFacts.csrf, sensitive,
1716
+ version: 1, path: currentPath, title: s.title, user: requestFacts.actor ?? '', csrf: requestFacts.csrf, sensitive, inPlace,
1717
+ ...(work === null ? {} : { work }),
1718
+ // Said on the document that ships it: this document's page script listens for as long as the document lives.
1719
+ ...(functional !== '' && !requestFacts.workspaceRead ? { scripted: true } : {}),
1674
1720
  // The project picker names the open project; the settings frame carries its breadcrumb.
1675
1721
  // Only a project this person may see now: never the raw saved path (settings-kit.ts openProjectFor).
1676
1722
  openProject: openProjectFor(requestFacts.openProject ?? null, s.chrome.project, s.chrome.projects ?? []),
@@ -1761,10 +1807,6 @@ export function createDecisionServer(options) {
1761
1807
  return respond(response, status, 'application/json; charset=utf-8', body);
1762
1808
  }
1763
1809
  const nonce = randomBytes(16).toString('base64');
1764
- // React owns the conversation and its refresh/draft lifecycle. Native
1765
- // guarded forms keep their current behavior after React inserts them.
1766
- // A page with its own view keeps its script; the chat page runs the result script.
1767
- const functional = conversation === null || extras.view ? (s.functional?.script ?? '') : RESULT_REVIEW_SCRIPT;
1768
1810
  const document = shell(s.title, s.body, { chrome: s.chrome, sensitive });
1769
1811
  // The keys and their list (keymap.ts) work here as on every chromed page; the list sits beside the app React owns.
1770
1812
  // A sensitive page keeps to its own script.
@@ -1,4 +1,4 @@
1
- import { type BrowserSignIn, type BrowserUpdateNotice, type BrowserWorkspace } from "../browser-workspace.js";
1
+ import { type BrowserSignIn, type BrowserUpdateNotice, type BrowserWorkspace, type PageWork } from "../browser-workspace.js";
2
2
  import { type Crumb } from "../settings-kit.js";
3
3
  import { type Decision, type Run } from "../store.js";
4
4
  import { type tally } from "../summary.js";
@@ -176,6 +176,9 @@ export type Screen = {
176
176
  functional?: {
177
177
  script: string;
178
178
  fetches?: boolean;
179
+ /** The app's own version of this WHOLE script (task-page-in-app.md §1): where the app can run it, the document
180
+ * ships no script string and the page may change in place. Added to only through withScript. */
181
+ app?: readonly PageWork[];
179
182
  };
180
183
  refreshSeconds?: number;
181
184
  /** Render sensitive even when no password field is visible — one-time
@@ -317,3 +320,9 @@ export declare function oneLineOf(text: string, cap: number): string;
317
320
  * minutes are the answer without script.
318
321
  */
319
322
  export declare function boardTickScript(regionId?: string): string;
323
+ /**
324
+ * A page's script with one more added (docs/plans/task-page-in-app.md §1). All or nothing: `app` survives only when
325
+ * every part has it, so a script the app does not cover keeps the whole page a document, and a page never gets half
326
+ * its behaviour from the string and half from the app.
327
+ */
328
+ export declare function withScript(base: Screen["functional"], add: NonNullable<Screen["functional"]>): NonNullable<Screen["functional"]>;
@@ -807,3 +807,12 @@ export function boardTickScript(regionId = "board-region") {
807
807
  `run();setInterval(function(){if(!document.hidden)run();},1000);document.addEventListener("visibilitychange",function(){if(!document.hidden)run();});` +
808
808
  `if(region&&typeof MutationObserver==="function")new MutationObserver(run).observe(region,{childList:true});})();`);
809
809
  }
810
+ /**
811
+ * A page's script with one more added (docs/plans/task-page-in-app.md §1). All or nothing: `app` survives only when
812
+ * every part has it, so a script the app does not cover keeps the whole page a document, and a page never gets half
813
+ * its behaviour from the string and half from the app.
814
+ */
815
+ export function withScript(base, add) {
816
+ const app = base === undefined ? add.app : base.app !== undefined && add.app !== undefined ? [...base.app, ...add.app] : undefined;
817
+ return { script: (base?.script ?? "") + add.script, fetches: (base?.fetches ?? false) || (add.fetches ?? false), ...(app === undefined ? {} : { app }) };
818
+ }
@@ -2662,14 +2662,29 @@ button { min-height: 44px; }
2662
2662
  the same on both, swaps in place instead of fading (each shell names its
2663
2663
  own, so a page can never carry the name twice). */
2664
2664
  ::view-transition-old(root) { animation: so-page-leave 80ms ease-in both; }
2665
- ::view-transition-new(root) { animation: so-page-arrive 140ms ease-out 60ms both; }
2665
+ ::view-transition-new(root) { animation: so-page-arrive 160ms ease-out 60ms both; }
2666
2666
  @keyframes so-page-leave { to { opacity: 0; } }
2667
2667
  @keyframes so-page-arrive { from { opacity: 0; } }
2668
2668
  .side { view-transition-name: so-nav-page; }
2669
2669
  .so-sidebar { view-transition-name: so-nav-app; }
2670
- ::view-transition-group(so-nav-page), ::view-transition-group(so-nav-app),
2670
+ .so-ph { view-transition-name: so-phone-header; }
2671
+ ::view-transition-group(so-nav-page), ::view-transition-group(so-nav-app), ::view-transition-group(so-phone-header), ::view-transition-group(so-message-box),
2671
2672
  ::view-transition-old(so-nav-page), ::view-transition-new(so-nav-page),
2672
- ::view-transition-old(so-nav-app), ::view-transition-new(so-nav-app) { animation: none; }
2673
+ ::view-transition-old(so-nav-app), ::view-transition-new(so-nav-app),
2674
+ ::view-transition-old(so-phone-header), ::view-transition-new(so-phone-header),
2675
+ ::view-transition-old(so-message-box), ::view-transition-new(so-message-box) { animation: none; }
2676
+ /* A page changed in place (docs/plans/live-pages-and-home.md A2.2, browser/transition.ts): only the page content
2677
+ moves; everything else (the root, the sidebar, the phone header, the message box) swaps where it stands. The same
2678
+ level fades through like a document load (80 ms out, 160 ms in); deeper, the new page also moves 8px in from the
2679
+ right over 200 ms; shallower, from the left. Nothing runs past 220 ms. */
2680
+ html.so-nav #workspace-main { view-transition-name: so-page; }
2681
+ html.so-nav::view-transition-old(root), html.so-nav::view-transition-new(root), html.so-nav::view-transition-group(so-page) { animation: none; }
2682
+ html.so-nav::view-transition-old(so-page) { animation: so-page-leave 80ms ease-in both; }
2683
+ html.so-nav-same::view-transition-new(so-page) { animation: so-page-arrive 160ms ease-out 60ms both; }
2684
+ html.so-nav-deeper::view-transition-new(so-page) { animation: so-page-deeper 200ms cubic-bezier(.2, .8, .2, 1) both; }
2685
+ html.so-nav-shallower::view-transition-new(so-page) { animation: so-page-shallower 200ms cubic-bezier(.2, .8, .2, 1) both; }
2686
+ @keyframes so-page-deeper { from { opacity: 0; transform: translateX(8px); } }
2687
+ @keyframes so-page-shallower { from { opacity: 0; transform: translateX(-8px); } }
2673
2688
  /* UI polish 2026-09-13 — the motion contract. Feedback transitions run
2674
2689
  140–200 ms on color, border, shadow, opacity, and transform only; an
2675
2690
  overlay's entrance is at most 220 ms of opacity + transform. Nothing
@@ -2699,7 +2714,7 @@ button { min-height: 44px; }
2699
2714
  @keyframes fade { from { opacity: 0; } }
2700
2715
  @media (prefers-reduced-motion: reduce) {
2701
2716
  *, *::before, *::after { animation: none !important; transition: none !important; }
2702
- ::view-transition-group(*), ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
2717
+ ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
2703
2718
  button:active, :is(.button-link, .side .new-task, .content .new-task, .result-feedback-link, .next-pager .skip):active { transform: none; }
2704
2719
  @media (hover: hover) and (pointer: fine) { button:hover, .side nav a:hover, .chat-project-card:hover, .lane-card:hover, .decide-card:hover, .menu-row:hover { transform: none; } }
2705
2720
  }
@@ -1,14 +1,20 @@
1
1
  /**
2
- * Home ("/", UI revamp wave B): one sentence that answers "what needs me?", four counts read from the same work index
3
- * the Tasks list filters (so a count and the list it opens always agree), the work in three groups (Waiting on you,
4
- * Working, Done; home-groups.ts), and the lead's message box. Reads only: every act keeps its own route and rules (an agent's
5
- * question is answered through POST /d/<id>/answer, the same one-click act as its own page).
2
+ * Home ("/", docs/plans/live-pages-and-home.md B1): what needs this person, grouped by what is asked (Approve a plan,
3
+ * Answer a question, Something went wrong, Review results, and Needs you for the rest), each group with its exact
4
+ * count; one quiet line for what runs and waits; with nothing waiting, what runs and what finished today. The figures
5
+ * are read from the same work index the Tasks list filters, so a count and the list it opens always agree. Reads
6
+ * only: every act keeps its own route and rules (an agent's question is answered through POST /d/<id>/answer, the
7
+ * same one-click act as its own page).
6
8
  */
7
- import type { BrowserHomeView } from "../browser-workspace.js";
9
+ import type { BrowserHomeAsk, BrowserHomeView } from "../browser-workspace.js";
8
10
  import type { Store } from "../store.js";
9
11
  import type { WorkSummaryAccess } from "../work-summary.js";
10
- /** How many waiting rows Home lists; the rest open in Tasks. */
12
+ /** How many waiting rows Home lists; each group says how many more open in Tasks. */
11
13
  export declare const HOME_NEEDS_SHOWN = 30;
14
+ /** How many rows finished today Home lists; its figure counts them all. */
15
+ export declare const HOME_DONE_SHOWN = 8;
16
+ /** Each group's heading, in the order Home shows them. */
17
+ export declare const HOME_ASK_LABEL: Readonly<Record<BrowserHomeAsk, string>>;
12
18
  export type HomeFacts = {
13
19
  store: Store;
14
20
  now: Date;
@@ -16,7 +22,7 @@ export type HomeFacts = {
16
22
  evidenceRoot?: string;
17
23
  /** The project in view (already admitted), or null for every project. */
18
24
  project: string | null;
19
- /** Every admitted project, for the scope links. */
25
+ /** Every admitted project, for the project filter. */
20
26
  projects: readonly {
21
27
  name: string;
22
28
  path: string;
@@ -31,12 +37,19 @@ export type HomeFacts = {
31
37
  why: string;
32
38
  road: "scope" | "agents";
33
39
  } | null;
40
+ /** The lead's last message in this person's main thread, already read through the chat's own access check
41
+ * (pages.ts leadLastMessage); null when there is none or this person may not read that thread. */
42
+ leadMessage?: {
43
+ text: string;
44
+ at: string;
45
+ } | null;
34
46
  };
35
- /** "7 need you across 2 projects." — the sentence and the figures read the same totals. */
36
- export declare function homeSentence(needs: number, perProject: readonly {
37
- name: string;
38
- needs: number;
39
- }[], scope: string | null, any: boolean): string;
40
- /** Home's view for one person and scope, from four reads of the work index (what waits on you, what runs, what waits on
41
- * Toolroll, what was done), grouped by the one rule every surface shares (home-groups.ts). */
47
+ /** "19 tasks need you": the heading, said once, from the same total as the rail's count. */
48
+ export declare function homeSentence(needs: number, any: boolean): string;
49
+ /** The lead's words as one plain line: no markdown marks, no line breaks, cut to about one line. */
50
+ export declare function leadLineOf(text: string): string;
51
+ /** Midnight today on this computer's clock: "finished today" starts here. */
52
+ export declare function startOfToday(now: Date): Date;
53
+ /** Home's view for one person and scope, from four reads of the work index: what waits on you (with exact per-ask
54
+ * totals), what runs, what waits its turn, and what was completed today (filtered before its limit). */
42
55
  export declare function homeView(facts: HomeFacts): BrowserHomeView;