toolroll 0.9.83 → 0.9.84

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +4 -3
  3. package/dist/assignment-ui.js +5 -1
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +22 -22
  6. package/dist/browser-crew.js +5 -1
  7. package/dist/browser-workspace.d.ts +7 -0
  8. package/dist/contracts/console-api.d.ts +2 -1
  9. package/dist/contracts/console-api.js +5 -1
  10. package/dist/control-setup.d.ts +9 -0
  11. package/dist/control-setup.js +31 -4
  12. package/dist/control-ui.d.ts +7 -1
  13. package/dist/control-ui.js +3 -2
  14. package/dist/desktop-update.d.ts +1 -1
  15. package/dist/dispatch.d.ts +15 -0
  16. package/dist/dispatch.js +26 -4
  17. package/dist/first-run.d.ts +5 -2
  18. package/dist/first-run.js +13 -2
  19. package/dist/folder-look.d.ts +3 -0
  20. package/dist/folder-look.js +8 -4
  21. package/dist/guides.js +4 -3
  22. package/dist/model-catalog.d.ts +5 -0
  23. package/dist/model-catalog.js +11 -2
  24. package/dist/model-choices.d.ts +188 -0
  25. package/dist/model-choices.js +405 -0
  26. package/dist/model-names.d.ts +6 -0
  27. package/dist/model-names.js +34 -0
  28. package/dist/models-ui.d.ts +69 -5
  29. package/dist/models-ui.js +53 -10
  30. package/dist/needs-you.d.ts +8 -0
  31. package/dist/needs-you.js +8 -0
  32. package/dist/operate.d.ts +21 -0
  33. package/dist/operate.js +96 -26
  34. package/dist/project-admission.d.ts +4 -1
  35. package/dist/project-admission.js +23 -2
  36. package/dist/project-identity.d.ts +9 -2
  37. package/dist/project-identity.js +80 -33
  38. package/dist/project-knowledge.d.ts +6 -6
  39. package/dist/serve.js +60 -13
  40. package/dist/server/chat.js +5 -4
  41. package/dist/server/chrome.d.ts +3 -0
  42. package/dist/server/chrome.js +5 -2
  43. package/dist/server/console.css +5 -2
  44. package/dist/server/flows.js +4 -0
  45. package/dist/server/http.d.ts +1 -0
  46. package/dist/server/http.js +6 -0
  47. package/dist/server/pages.js +42 -6
  48. package/dist/server/people-tokens.js +40 -20
  49. package/dist/server/render-pages.d.ts +10 -0
  50. package/dist/server/render-pages.js +29 -14
  51. package/dist/server/render-people.d.ts +14 -2
  52. package/dist/server/render-people.js +35 -12
  53. package/dist/server/render-settings.js +1 -0
  54. package/dist/server/render-tasks.js +7 -14
  55. package/dist/server/request-context.d.ts +2 -0
  56. package/dist/server/route-table.js +8 -1
  57. package/dist/server/settings.d.ts +58 -1
  58. package/dist/server/settings.js +198 -69
  59. package/dist/server/signup-gate.d.ts +9 -0
  60. package/dist/server/signup-gate.js +40 -0
  61. package/dist/server/tasks.js +12 -2
  62. package/dist/settings-kit-script.d.ts +21 -0
  63. package/dist/settings-kit-script.js +67 -0
  64. package/dist/settings-kit.d.ts +137 -0
  65. package/dist/settings-kit.js +186 -0
  66. package/dist/settings-proofs.d.ts +64 -0
  67. package/dist/settings-proofs.js +101 -0
  68. package/dist/setup-guide.js +4 -2
  69. package/dist/spend-report.d.ts +87 -0
  70. package/dist/spend-report.js +265 -0
  71. package/dist/spend-ui.d.ts +29 -8
  72. package/dist/spend-ui.js +74 -39
  73. package/dist/task-sizing.d.ts +5 -3
  74. package/dist/task-sizing.js +4 -4
  75. package/package.json +1 -1
@@ -2,7 +2,8 @@
2
2
  * Project identities as the console uses them, without touching a person's folders on its thread.
3
3
  *
4
4
  * A project's identity is its real path (project.ts canonicalProject). Resolving one reads the folder, and a folder can
5
- * stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread:
5
+ * stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread, and
6
+ * never waits for one before it answers:
6
7
  *
7
8
  * - `hot` (display and grouping only): the last answer, else the path as saved. Nothing is started by asking.
8
9
  * - `strict` (visibility and authorization): only a resolved identity; an unknown one fails closed until the next
@@ -10,7 +11,8 @@
10
11
  * - `vouched` (a path a request names): only a path whose identity is known, or one that is exactly a saved identity.
11
12
  * Anything else is refused this time and resolved by the next background pass, never resolved inline.
12
13
  * - `resolveNow` (an action that needs a path checked now, a POST): resolved off the thread under the look's deadline.
13
- * - `refresh` (the console's start and its background pass): resolves what was asked about and what has gone stale.
14
+ * - `refresh` (the console's background pass, the first started right after it listens): resolves what was asked about
15
+ * and what has gone stale. The console answers before any of it does; until a path answers it fails closed.
14
16
  *
15
17
  * An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
16
18
  */
@@ -31,7 +33,12 @@ export type ProjectIdentities = {
31
33
  resolveNow: (path: string) => Promise<string | null | "no-answer">;
32
34
  /** Resolve `paths`, everything asked about since, and answers older than a minute. False when some didn't answer. */
33
35
  refresh: (paths: Iterable<string>) => Promise<boolean>;
36
+ /** Moves whenever a known identity changes or goes: a decision made before an await is re-checked against it right
37
+ * before its write. */
38
+ generation: () => number;
34
39
  };
40
+ /** How many folders a pass looks at side by side when they're looked at one by one (within FOLDER_LOOK_CHILDREN). */
41
+ export declare const IDENTITY_LOOKS = 4;
35
42
  export declare function projectIdentities(look?: FolderLookOptions, now?: () => number): ProjectIdentities;
36
43
  /** The synchronous reads, as before: for embedders and tests that pass no identities. */
37
44
  export declare function syncProjectIdentities(): ProjectIdentities;
@@ -2,7 +2,8 @@
2
2
  * Project identities as the console uses them, without touching a person's folders on its thread.
3
3
  *
4
4
  * A project's identity is its real path (project.ts canonicalProject). Resolving one reads the folder, and a folder can
5
- * stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread:
5
+ * stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread, and
6
+ * never waits for one before it answers:
6
7
  *
7
8
  * - `hot` (display and grouping only): the last answer, else the path as saved. Nothing is started by asking.
8
9
  * - `strict` (visibility and authorization): only a resolved identity; an unknown one fails closed until the next
@@ -10,7 +11,8 @@
10
11
  * - `vouched` (a path a request names): only a path whose identity is known, or one that is exactly a saved identity.
11
12
  * Anything else is refused this time and resolved by the next background pass, never resolved inline.
12
13
  * - `resolveNow` (an action that needs a path checked now, a POST): resolved off the thread under the look's deadline.
13
- * - `refresh` (the console's start and its background pass): resolves what was asked about and what has gone stale.
14
+ * - `refresh` (the console's background pass, the first started right after it listens): resolves what was asked about
15
+ * and what has gone stale. The console answers before any of it does; until a path answers it fails closed.
14
16
  *
15
17
  * An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
16
18
  */
@@ -19,27 +21,86 @@ import { lookIdentities } from "./folder-look.js";
19
21
  import { canonicalProject } from "./project.js";
20
22
  /** How long an answer stands before the background pass resolves the path again. */
21
23
  const STALE_MS = 60_000;
24
+ /** How many folders a pass looks at side by side when they're looked at one by one (within FOLDER_LOOK_CHILDREN). */
25
+ export const IDENTITY_LOOKS = 4;
22
26
  export function projectIdentities(look = {}, now = Date.now) {
27
+ /** Each answer with when the look that answered it started (folder-look.ts `started`, kept across joined looks): an
28
+ * answer from a look started earlier never overwrites one from a look started later. */
23
29
  const answers = new Map();
24
30
  const wanted = new Set();
31
+ /** Paths whose last look didn't answer: looked at only on their own (joining a look still under way), never in a
32
+ * batch, so one folder that never answers holds one child, not one per differently made-up batch. */
33
+ const suspect = new Set();
34
+ let generation = 0;
25
35
  const known = (path) => answers.get(resolve(path))?.real;
26
- const store = (found) => {
36
+ const put = (key, real, at, seq) => {
37
+ const before = answers.get(key);
38
+ // A known identity that changes (or goes) moves authority: anything decided on the old one is out of date.
39
+ if (before !== undefined && before.real !== real)
40
+ generation++;
41
+ answers.set(key, { real, at, seq });
42
+ };
43
+ const store = (found, seq) => {
27
44
  const at = now();
28
45
  for (const [path, real] of found) {
29
- answers.set(resolve(path), { real, at });
30
- wanted.delete(resolve(path));
46
+ const key = resolve(path);
47
+ // Superseded: a later lookup already answered for this path. Its real path's entry goes with it.
48
+ if ((answers.get(key)?.seq ?? -1) > seq)
49
+ continue;
50
+ put(key, real, at, seq);
51
+ wanted.delete(key);
31
52
  // A real path is its own identity.
32
- if (real !== null)
33
- answers.set(real, { real, at });
53
+ if (real !== null && (answers.get(real)?.seq ?? -1) <= seq)
54
+ put(real, real, at, seq);
34
55
  }
35
56
  };
36
57
  const resolveNow = async (path) => {
37
- const looked = await lookIdentities([resolve(path)], look);
38
- if (!looked.ok)
58
+ const key = resolve(path);
59
+ const looked = await lookIdentities([key], look);
60
+ if (!looked.ok) {
61
+ suspect.add(key);
39
62
  return "no-answer";
40
- store(looked.value);
41
- return looked.value.get(resolve(path)) ?? null;
63
+ }
64
+ suspect.delete(key);
65
+ // No start known: older than every answer, so it only fills what nothing answered yet.
66
+ store(looked.value, looked.started ?? 0);
67
+ return answers.get(key)?.real ?? null;
68
+ };
69
+ const refreshNow = async (paths) => {
70
+ const at = now();
71
+ const due = new Set(wanted);
72
+ for (const one of paths) {
73
+ const key = resolve(one);
74
+ const answer = answers.get(key);
75
+ if (answer === undefined || at - answer.at >= STALE_MS)
76
+ due.add(key);
77
+ }
78
+ for (const [key, answer] of answers)
79
+ if (at - answer.at >= STALE_MS)
80
+ due.add(key);
81
+ if (due.size === 0)
82
+ return true;
83
+ const alone = [...due].filter(one => suspect.has(one));
84
+ const batched = [...due].filter(one => !suspect.has(one));
85
+ if (batched.length > 0) {
86
+ const batch = await lookIdentities(batched, look);
87
+ if (batch.ok)
88
+ store(batch.value, batch.started ?? 0);
89
+ else
90
+ alone.push(...batched);
91
+ }
92
+ // One folder that doesn't answer mustn't hold the others: each on its own, a few at a time (inside the looks'
93
+ // child cap, leaving room for a person's actions), each under its deadline, each stored as soon as it answers.
94
+ let missed = false;
95
+ const worker = async () => {
96
+ for (let one = alone.shift(); one !== undefined; one = alone.shift())
97
+ if (await resolveNow(one) === "no-answer")
98
+ missed = true;
99
+ };
100
+ await Promise.all(Array.from({ length: Math.min(IDENTITY_LOOKS, alone.length) }, worker));
101
+ return !missed;
42
102
  };
103
+ let passes = Promise.resolve();
43
104
  return {
44
105
  hot: path => {
45
106
  const key = resolve(path);
@@ -68,28 +129,13 @@ export function projectIdentities(look = {}, now = Date.now) {
68
129
  return undefined;
69
130
  },
70
131
  resolveNow,
71
- refresh: async (paths) => {
72
- const at = now();
73
- const due = new Set(wanted);
74
- for (const one of paths) {
75
- const key = resolve(one);
76
- const answer = answers.get(key);
77
- if (answer === undefined || at - answer.at >= STALE_MS)
78
- due.add(key);
79
- }
80
- for (const [key, answer] of answers)
81
- if (at - answer.at >= STALE_MS)
82
- due.add(key);
83
- if (due.size === 0)
84
- return true;
85
- const batch = await lookIdentities([...due], look);
86
- if (batch.ok) {
87
- store(batch.value);
88
- return true;
89
- }
90
- // One folder that doesn't answer mustn't hold the others: each on its own, in parallel (the looks are capped).
91
- const each = await Promise.all([...due].map(one => resolveNow(one)));
92
- return each.every(one => one !== "no-answer");
132
+ generation: () => generation,
133
+ refresh: paths => {
134
+ // One pass at a time: a pass asked for while another runs waits for it, then resolves only what is still due.
135
+ const asked = [...paths];
136
+ const pass = passes.then(() => refreshNow(asked));
137
+ passes = pass.then(() => undefined, () => undefined);
138
+ return pass;
93
139
  },
94
140
  };
95
141
  }
@@ -101,6 +147,7 @@ export function syncProjectIdentities() {
101
147
  strict: path => canonicalProject(path),
102
148
  vouched: path => canonicalProject(path) ?? undefined,
103
149
  resolveNow: async (path) => canonicalProject(path),
150
+ generation: () => 0,
104
151
  refresh: async () => true,
105
152
  };
106
153
  }
@@ -22,18 +22,18 @@ 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
- history?: never;
25
+ title?: never;
26
26
  revision: number;
27
27
  stale: boolean;
28
28
  notice: string;
29
29
  decision?: never;
30
- title?: never;
31
30
  content?: never;
31
+ history?: never;
32
32
  instructions?: never;
33
33
  references?: never;
34
34
  decisions?: never;
35
35
  } | {
36
- history?: never;
36
+ title?: never;
37
37
  stale?: never;
38
38
  decision: {
39
39
  repo: string;
@@ -50,27 +50,27 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
50
50
  id: number;
51
51
  };
52
52
  notice: string;
53
- title?: never;
54
53
  content?: never;
55
54
  revision?: never;
55
+ history?: never;
56
56
  instructions?: never;
57
57
  references?: never;
58
58
  decisions?: never;
59
59
  } | {
60
- history?: never;
61
60
  stale?: never;
62
61
  decision?: never;
63
62
  revision: number;
64
63
  title: string;
65
64
  content: string;
66
65
  notice: string;
66
+ history?: never;
67
67
  instructions?: never;
68
68
  references?: never;
69
69
  decisions?: never;
70
70
  } | {
71
+ title?: never;
71
72
  stale?: never;
72
73
  decision?: never;
73
- title?: never;
74
74
  content?: never;
75
75
  revision: number;
76
76
  history: {
package/dist/serve.js CHANGED
@@ -146,6 +146,7 @@ import { createChatHandlers } from './server/chat.js';
146
146
  import { createLiveHandlers } from './server/live.js';
147
147
  import { createTerminalHandlers, terminalHostFor } from './server/terminals.js';
148
148
  import { passwordlessOn } from './passwordless.js';
149
+ import { keepTypedFields, openProjectFor, settingsCrumbs } from './settings-kit.js';
149
150
  import { createSweepHandlers } from './server/sweeps.js';
150
151
  import { createFlowsHandlers } from './server/flows.js';
151
152
  import { createPagesHandlers } from './server/pages.js';
@@ -200,7 +201,7 @@ export function createDecisionServer(options) {
200
201
  get managedRepos() { return managedRepos; },
201
202
  get ceiling() { return ceiling; },
202
203
  get allowedHost() { return allowedHost; },
203
- get defaultProject() { return defaultProject; },
204
+ get defaultProject() { return defaultProject(); },
204
205
  get actionTarget() { return actionTarget; },
205
206
  get team() { return team; },
206
207
  get joinSourceOf() { return joinSourceOf; },
@@ -453,15 +454,14 @@ export function createDecisionServer(options) {
453
454
  };
454
455
  let joinGlobal = { tokens: 30, refilledAt: Date.now() };
455
456
  const approvalNonces = new Map();
456
- // Resolve startup authority once. Missing paths remain restrictive.
457
- // Native additions come only from the controller after its admission checks.
458
- const { ceiling: startCeiling, unresolved: unresolvedRepos } = resolveCeiling([...(options.repo === undefined ? [] : [options.repo]), ...(options.repos ?? [])], options.projectRoots ?? [], options.identities === undefined ? undefined : identities.known);
459
- const ceiling = { ...startCeiling, canon: identities.strict };
460
- /** The project every fresh session opens with: the sole configured repo, else none. */
461
- const defaultProject = ceiling.repos.length === 1 && ceiling.roots.length === 0 ? ceiling.repos[0] : null;
457
+ // Startup authority: the configured repos and roots as their identities say (startupCeiling). Missing or unresolved
458
+ // paths remain restrictive. Native additions come only from the controller after its admission checks.
459
+ const { ceiling, unresolved: unresolvedRepos, defaultProject, configured, configuredPaths } = startupCeiling([...(options.repo === undefined ? [] : [options.repo]), ...(options.repos ?? [])], options.projectRoots ?? [], options.identities === undefined ? undefined : identities, identities.strict);
462
460
  /** No ceiling configured at all: the legacy trust-everything mode, named. */
463
461
  // An `up` console (it is handed its admitted set) is never unscoped: with every saved project away it is empty.
464
- const unscopedMode = ceiling.repos.length === 0 && ceiling.roots.length === 0 && options.admittedRepos === undefined;
462
+ // Judged by what was configured, never by what has resolved so far: a console whose folders haven't answered yet is
463
+ // scoped and empty, not unscoped.
464
+ const unscopedMode = !configured && options.admittedRepos === undefined;
465
465
  /** Per-row visibility under the ceiling — the authorization question for reads. */
466
466
  /** The exact repositories this console serves now: `up`'s live admitted set when it supplies one, else startup's. */
467
467
  const exactRepos = () => options.admittedRepos?.() ?? ceiling.repos;
@@ -889,6 +889,7 @@ export function createDecisionServer(options) {
889
889
  browser: who.via === 'cookie',
890
890
  // Passwordless (read once for rendering; every act proves it again at the act): ceremony fields render as nothing.
891
891
  passwordless: who.via === 'cookie' && passwordlessOn(store),
892
+ openProject: who.via === 'cookie' ? who.session.project : null,
892
893
  // v100: signed in with the identity provider: its label, and whether it checked them recently enough to stand in for a password.
893
894
  // Cockpit B2: "fresh" is an explicit re-check made on request and not yet spent (read live; one step-up uses it).
894
895
  sso: who.via === 'cookie' && who.session.sso !== undefined ? { label: ssoSettings()?.label ?? "your identity provider",
@@ -1327,8 +1328,8 @@ export function createDecisionServer(options) {
1327
1328
  return { ok: false, code: "demo", why: "this is a demo database — chat cannot contact an external model" };
1328
1329
  if (unscopedMode)
1329
1330
  return { ok: false, code: "unscoped", why: "chat needs at least one added project" };
1330
- if (options.currentRepos === undefined && unresolvedRepos.length > 0) {
1331
- return { ok: false, code: "unresolved", why: "a configured project path did not resolve at startup — fix it and restart before chat will run" };
1331
+ if (options.currentRepos === undefined && unresolvedRepos().length > 0) {
1332
+ return { ok: false, code: "unresolved", why: "a configured project folder hasn't been found yet — chat runs once it answers; if its path is wrong, fix it and restart" };
1332
1333
  }
1333
1334
  if (managedRepos().length === 0)
1334
1335
  return { ok: false, code: "empty", why: "add a project first — chat will include it automatically" };
@@ -1483,13 +1484,20 @@ export function createDecisionServer(options) {
1483
1484
  const ssoFacts = requestContext.getStore()?.sso;
1484
1485
  if (ssoFacts !== undefined) {
1485
1486
  const back = requestContext.getStore()?.returnTo ?? "/";
1486
- s = { ...s, body: ssoStepUps(s.body, ssoFacts, back), ...(s.workspace?.pageHtml == null ? {} : { workspace: { ...s.workspace, pageHtml: ssoStepUps(s.workspace.pageHtml, ssoFacts, back) } }) };
1487
+ // A typed-only proof (settings-kit.ts) stays a password field: its handler takes nothing else.
1488
+ s = { ...s, body: keepTypedFields(s.body, ssoStepUps, ssoFacts, back), ...(s.workspace?.pageHtml == null ? {} : { workspace: { ...s.workspace, pageHtml: keepTypedFields(s.workspace.pageHtml, ssoStepUps, ssoFacts, back) } }) };
1487
1489
  if (s.workspace?.view?.kind === "flow")
1488
1490
  s = { ...s, workspace: { ...s.workspace, view: { ...s.workspace.view, stepUp: { label: ssoFacts.label, fresh: ssoFacts.fresh, confirmHref: `/login/sso?reauth=1&return=${encodeURIComponent(back)}` } } } };
1489
1491
  }
1490
1492
  // D5: team chat (the central team service) is deprecated; /chat?team=1 still opens this release, but nothing links to it.
1491
1493
  const sensitive = authored || s.forceSensitive === true;
1492
1494
  const requestFacts = requestContext.getStore();
1495
+ // The settings frame (settings-kit.ts): its breadcrumb, for the shell and the workspace alike.
1496
+ if (s.chrome !== undefined && s.chrome.crumbs === undefined) {
1497
+ const crumbs = settingsCrumbs(new URL(requestFacts?.returnTo ?? '/', 'http://standing-orders.local').pathname, s.title, htmlString(s.body));
1498
+ if (crumbs !== undefined)
1499
+ s = { ...s, chrome: { ...s.chrome, crumbs } };
1500
+ }
1493
1501
  if (requestFacts?.browser && s.chrome && !s.forceSensitive && (requestFacts.workspaceRead || browserAssetsAvailable())) {
1494
1502
  const path = new URL(requestFacts.returnTo, 'http://standing-orders.local');
1495
1503
  path.searchParams.delete('format');
@@ -1564,6 +1572,10 @@ export function createDecisionServer(options) {
1564
1572
  const request = requestFacts.workspaceRequest;
1565
1573
  const workspace = {
1566
1574
  version: 1, path: currentPath, title: s.title, user: requestFacts.actor ?? '', csrf: requestFacts.csrf, sensitive,
1575
+ // The project picker names the open project; the settings frame carries its breadcrumb.
1576
+ // Only a project this person may see now: never the raw saved path (settings-kit.ts openProjectFor).
1577
+ openProject: openProjectFor(requestFacts.openProject ?? null, s.chrome.project, s.chrome.projects ?? []),
1578
+ ...(s.chrome.crumbs === undefined ? {} : { crumbs: s.chrome.crumbs }),
1567
1579
  ...(requestFacts.passwordless === true ? { passwordless: true } : {}),
1568
1580
  ...(requestFacts.actor ? { leadName: leadNameOf(store, requestFacts.actor) } : {}),
1569
1581
  refreshUrl: path.pathname + path.search,
@@ -1966,7 +1978,7 @@ export function createDecisionServer(options) {
1966
1978
  waits.push(connectionCheck(provider).catch(() => undefined));
1967
1979
  }
1968
1980
  // Project identities, resolved off the thread: what pages asked about, and answers gone stale.
1969
- waits.push(identities.refresh([...exactRepos(), ...(options.currentRepos?.() ?? []), ...store.listProjects().map(one => one.path), ...store.knownRepos(), ...ceiling.roots]));
1981
+ waits.push(identities.refresh([...configuredPaths, ...exactRepos(), ...(options.currentRepos?.() ?? []), ...store.listProjects().map(one => one.path), ...store.knownRepos()]));
1970
1982
  integrations.refreshIfDue();
1971
1983
  const sso = ssoSettings();
1972
1984
  if (sso !== null && (ssoProvider === null || ssoProvider.issuer !== sso.issuer || Date.now() - ssoProvider.at >= 10 * 60_000))
@@ -2204,7 +2216,7 @@ export function createDecisionServer(options) {
2204
2216
  get providerFor() { return providerFor; },
2205
2217
  get providerKnown() { return providerKnown; },
2206
2218
  get connectVisits() { return connectVisits; },
2207
- get defaultProject() { return defaultProject; },
2219
+ get defaultProject() { return defaultProject(); },
2208
2220
  get googleVisits() { return googleVisits; },
2209
2221
  get setupAttemptsLeft() { return setupAttemptsLeft; },
2210
2222
  set setupAttemptsLeft(value) { setupAttemptsLeft = value; },
@@ -2324,3 +2336,38 @@ export { diffFileAnchor, editorFileHref, needActionOf, orderChangedFiles, plainC
2324
2336
  export { settingsGroups, settingsTiles } from "./server/render-settings.js";
2325
2337
  export { decisionAnswerScript, earlierVersionsWords, revisionLineageWords } from "./server/render-tasks.js";
2326
2338
  export { SIGN_IN_LINK_MS, SIGN_IN_LINK_PATH } from "./server/session.js";
2339
+ /**
2340
+ * The console's configured repos and roots. Without identities (embedders and tests) they resolve once, synchronously,
2341
+ * as before. With them, the console resolves them in the background after it listens (operate.ts startConsole), so
2342
+ * each is read from the last answer: a configured path counts by its real path from the moment its folder answers,
2343
+ * and until then only by its spelling, which the strict `canon` never matches for an unresolved row.
2344
+ */
2345
+ function startupCeiling(repos, roots, identities, canon) {
2346
+ const configured = repos.length > 0 || roots.length > 0;
2347
+ const configuredPaths = [...repos, ...roots];
2348
+ if (identities === undefined) {
2349
+ const once = resolveCeiling(repos, roots);
2350
+ return {
2351
+ ceiling: { ...once.ceiling, canon },
2352
+ unresolved: () => once.unresolved,
2353
+ defaultProject: () => once.ceiling.repos.length === 1 && once.ceiling.roots.length === 0 ? once.ceiling.repos[0] : null,
2354
+ configured,
2355
+ configuredPaths,
2356
+ };
2357
+ }
2358
+ // Only resolved entries authorize: an unresolved configured path is in no list, so it matches nothing (not even its
2359
+ // own spelling) and is no session's default until its folder answers.
2360
+ const resolved = (paths) => [...new Set(paths.flatMap(path => { const real = identities.known(path); return typeof real === "string" ? [real] : []; }))];
2361
+ const ceiling = {
2362
+ get repos() { return resolved(repos); },
2363
+ get roots() { return resolved(roots); },
2364
+ canon,
2365
+ };
2366
+ return {
2367
+ ceiling,
2368
+ unresolved: () => [...repos, ...roots].filter(path => typeof identities.known(path) !== "string"),
2369
+ defaultProject: () => repos.length === 1 && roots.length === 0 ? resolved(repos)[0] ?? null : null,
2370
+ configured,
2371
+ configuredPaths,
2372
+ };
2373
+ }
@@ -12,10 +12,10 @@ import { CHAT_ACTIONS, mintSharedActionReview, sharedActionPayload, sharedAction
12
12
  import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
13
13
  import { buildDataDocument, composeRequest, isSubscriptionChatProvider, parseAssistantEnvelope, performChatRequest, plausibleChatKey, priceOf, settleForPrice, TURN_WALL_CLOCK_MS, worstCaseForPrice } from "../converse.js";
14
14
  import { DEMO_CHAT_SCRIPT, demoChatHtml, demoThreadHtml } from "../demo-chat.js";
15
- import { withDispatchDiagnoses } from "../dispatch.js";
15
+ import { builderReadyFor, firstRunBuilderRepo, withDispatchDiagnoses } from "../dispatch.js";
16
16
  import { readVerifiedArtifact, scanForSecrets } from "../evidence.js";
17
17
  import { run as execRun } from "../exec.js";
18
- import { findFirstTasks, firstTaskSuggestions, HOW_IT_WORKS, SANDBOX_COMMAND } from "../first-run.js";
18
+ import { findFirstTasks, firstTaskSuggestions, HOW_IT_WORKS, SANDBOX_COMMAND, withBuilderStep } from "../first-run.js";
19
19
  import { leadBriefHtml } from '../lead-context.js';
20
20
  import { configureLeadFollow, leadFollowStatus } from '../lead-follow.js';
21
21
  import { leadActivity } from "../lead-voice.js";
@@ -1158,11 +1158,12 @@ export function createChatHandlers(runtime) {
1158
1158
  }
1159
1159
  /** Chat's first run: the steps, first tasks until the first task exists, and the sandbox while no agent is signed in. */
1160
1160
  function chatFirstRun(now, project) {
1161
- const steps = firstRunStepsNow(now);
1161
+ const repo = project ?? managedRepos()[0] ?? null;
1162
+ // Done only when a task in this project could start: a live builder bound to it, as dispatch decides.
1163
+ const steps = withBuilderStep(firstRunStepsNow(now), builderReadyFor(store, firstRunBuilderRepo(store, project, visible, managedRepos()), now));
1162
1164
  if (steps === null)
1163
1165
  return undefined;
1164
1166
  const taskFiled = steps.find(one => one.key === "task")?.done ?? true;
1165
- const repo = project ?? managedRepos()[0] ?? null;
1166
1167
  const agent = steps.find(one => one.key === "agent");
1167
1168
  const lead = leadWords();
1168
1169
  return {
@@ -1,4 +1,5 @@
1
1
  import { type BrowserSignIn, type BrowserUpdateNotice, type BrowserWorkspace } from "../browser-workspace.js";
2
+ import { type Crumb } from "../settings-kit.js";
2
3
  import { type Decision, type Run } from "../store.js";
3
4
  import { type tally } from "../summary.js";
4
5
  import { type ServerResponse } from "node:http";
@@ -115,6 +116,8 @@ export type Chrome = {
115
116
  csrf?: string;
116
117
  /** Where a switch made on this screen returns to. */
117
118
  returnTo?: string;
119
+ /** The settings frame's breadcrumb ("Settings / <page>", settings-kit.ts), set by sendScreen; absent elsewhere. */
120
+ crumbs?: Crumb[];
118
121
  };
119
122
  /**
120
123
  * A server-rendered region that stays current: when the page's live stream
@@ -33,6 +33,7 @@ import { PULL_REQUEST_SETTINGS_CSS } from "../pull-request-ui.js";
33
33
  import { RECIPE_CSS } from "../recipe-ui.js";
34
34
  import { REQUEST_LIMITS_CSS } from "../request-budget-ui.js";
35
35
  import { RETENTION_CSS } from "../retention-ui.js";
36
+ import { crumbsHtml, SETTINGS_KIT_CSS } from "../settings-kit.js";
36
37
  import { SKILLS_CSS } from "../skills-ui.js";
37
38
  import { SPEND_CSS } from "../spend-ui.js";
38
39
  import { SSO_CSS } from "../sso-ui.js";
@@ -148,7 +149,7 @@ export const INBOX_TABS_CSS = '.inbox-tabs{display:inline-flex;gap:2px;max-width
148
149
  '.inbox-ask{margin:18px 0 0}.inbox-ask>h2{display:flex;align-items:baseline;gap:8px;margin:0 0 4px;font-size:15px;font-weight:600}.inbox-ask>h2 .count{font:500 12px var(--so-mono,ui-monospace,monospace);font-variant-numeric:tabular-nums;color:var(--so-muted)}.inbox-ask h3{font-size:13px;font-weight:600;margin:12px 0 4px}' +
149
150
  '.inbox-unread{display:none;position:absolute;top:4px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--so-signal)}' +
150
151
  '@media (max-width:760px){.inbox-tabs{display:flex;width:100%}.inbox-tabs a{flex:1;justify-content:center;min-height:44px;padding:0 6px}.inbox-unread{display:block}}';
151
- export const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + SUBAGENT_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + REQUEST_LIMITS_CSS + PEOPLE_AUDIT_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
152
+ export const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + SUBAGENT_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + REQUEST_LIMITS_CSS + PEOPLE_AUDIT_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_CSS + SETTINGS_KIT_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
152
153
  /**
153
154
  * A server-rendered region that stays current: when the page's live stream
154
155
  * says something changed (GET /live, live-bus.ts), fetch this page's own
@@ -579,8 +580,10 @@ ${update === undefined ? "" : html `<div class="banner update-banner" data-updat
579
580
  ${postForm(update.dismissHref, html `<button type="submit">Dismiss</button>`, { attrs: { class: "inline" }, hidden: { version: update.version } })}</div>`}`;
580
581
  // The scope bar sits between the banners and the main/split body, so it
581
582
  // can never disappear with a responsive pane (portfolio arc §1).
583
+ // The settings frame's breadcrumb sits above the page's own h1 (its only h1).
584
+ const crumbs = chrome.crumbs === undefined ? "" : crumbsHtml(chrome.crumbs);
582
585
  const content = chrome.listPane === undefined
583
- ? html `<div class="content">${demoBanner}${scopeBar}<main>${main}</main></div>`
586
+ ? html `<div class="content">${demoBanner}${scopeBar}${crumbs}<main>${main}</main></div>`
584
587
  : html `<div class="content">${demoBanner}${scopeBar}<div class="split">\
585
588
  <div class="list-pane">${chrome.listPane}</div>\
586
589
  <div class="detail"><main>${main}</main></div>\
@@ -265,12 +265,15 @@
265
265
  .project-card .project-name:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: .375rem; }
266
266
 
267
267
  label { display: block; font-size: 0.8125rem; font-weight: 500; margin: .75rem 0 0; color: var(--foreground); }
268
- input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], input[type=search], input[type=time], textarea, select {
269
- width: 100%; margin: .35rem 0 0; padding: .45rem .7rem; font: 400 0.875rem/1.4 var(--font-sans);
268
+ :where(input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], input[type=search], input[type=time], textarea, select) {
269
+ width: 100%; margin: .35rem 0 0; padding: .45rem .7rem;
270
270
  color: var(--foreground); background: var(--card); min-height: 2.125rem;
271
271
  border: 1px solid var(--input); border-radius: calc(var(--radius) - 3px);
272
272
  transition: border-color .15s, box-shadow .15s;
273
273
  }
274
+ /* The box above is in :where() so a surface's own rules (the workspace's 44px phone targets) win; the type keeps
275
+ its old weight, so the phone's 16px rule still loses to it as before. */
276
+ input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], input[type=search], input[type=time], textarea, select { font: 400 0.875rem/1.4 var(--font-sans); }
274
277
  @media (hover: hover) and (pointer: fine) { input:hover, textarea:hover, select:hover { border-color: var(--muted-foreground); } }
275
278
  input[type=number] { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
276
279
  input[type=radio], input[type=checkbox] { accent-color: var(--ring); }
@@ -839,6 +839,7 @@ export function createFlowsHandlers(runtime) {
839
839
  if (body.get("repo") !== project || (who.via === "cookie" && body.get("projectRevision") !== String(who.session.projectRevision)))
840
840
  throw new RecipeError("The open project changed. Reopen this recipe in the intended project.", 409);
841
841
  const rootMode = !unscopedMode && ceiling.roots.length > 0;
842
+ const decidedOn = identities.generation();
842
843
  if (rootMode) {
843
844
  // A mutation's target is resolved now, off the console's thread, and must still be the project it names.
844
845
  const resolved = await identities.resolveNow(project);
@@ -852,6 +853,9 @@ export function createFlowsHandlers(runtime) {
852
853
  throw new RecipeError(rechecked.message, rechecked.status);
853
854
  if (projectOf(who, request) !== project)
854
855
  throw new RecipeError("The open project changed. Preview the intended project again.", 409);
856
+ // An identity that changed during the awaits above undoes what they proved.
857
+ if (identities.generation() !== decidedOn)
858
+ throw new RecipeError("A project folder changed while this was being checked. Try again.", 409);
855
859
  const revision = who.via === "cookie" ? who.session.projectRevision : 0;
856
860
  if (step === "prepare") {
857
861
  const id = body.get("recipe") ?? "", version = body.get("recipeRevision") ?? "";
@@ -226,6 +226,7 @@ export declare function matchTaskPath(pathname: string, suffixPattern: string):
226
226
  verb: string;
227
227
  } | null;
228
228
  export declare function taskHref(taskId: string): string;
229
+ export declare function runHref(runId: number): string;
229
230
  export declare const SAFETY: {
230
231
  readonly "Content-Security-Policy": "default-src 'none'; style-src 'self' 'unsafe-inline'; font-src 'self'; manifest-src 'self'; worker-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
231
232
  readonly "X-Content-Type-Options": "nosniff";
@@ -51,6 +51,9 @@ export function matchTaskPath(pathname, suffixPattern) {
51
51
  export function taskHref(taskId) {
52
52
  return `/t/${encodeURIComponent(taskId)}`;
53
53
  }
54
+ export function runHref(runId) {
55
+ return `/r/${Math.trunc(runId)}`;
56
+ }
54
57
  // ---- rendering -------------------------------------------------------------
55
58
  export const SAFETY = {
56
59
  "Content-Security-Policy": "default-src 'none'; style-src 'self' 'unsafe-inline'; font-src 'self'; manifest-src 'self'; worker-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
@@ -64,10 +67,13 @@ export function respond(response, status, type, body) {
64
67
  // writeHead's headers would otherwise win.
65
68
  const own = response.getHeader("content-security-policy");
66
69
  const cache = response.getHeader("cache-control");
70
+ // The first-account form's page sends `same-origin` so its own post names this console (signup-gate.ts).
71
+ const referrer = response.getHeader("referrer-policy");
67
72
  response.writeHead(status, {
68
73
  ...SAFETY,
69
74
  ...(own === undefined ? {} : { "content-security-policy": own }),
70
75
  ...(cache === undefined ? {} : { "Cache-Control": cache }),
76
+ ...(referrer === "same-origin" ? { "Referrer-Policy": "same-origin" } : {}),
71
77
  "Content-Type": type,
72
78
  });
73
79
  response.end(isHtml(body) ? htmlString(body) : body);