toolroll 0.9.83 → 0.9.85

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 (137) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +94 -1192
  3. package/dist/agent-fence.d.ts +3 -3
  4. package/dist/agent-fence.js +3 -3
  5. package/dist/agent-onboard.d.ts +2 -0
  6. package/dist/agent-onboard.js +3 -0
  7. package/dist/assignment-ui.js +5 -1
  8. package/dist/browser/workspace.css +2 -2
  9. package/dist/browser/workspace.js +31 -31
  10. package/dist/browser-crew.js +5 -1
  11. package/dist/browser-workspace.d.ts +12 -0
  12. package/dist/chat-channel.d.ts +10 -1
  13. package/dist/chat-channel.js +35 -5
  14. package/dist/chat-decide.d.ts +16 -4
  15. package/dist/chat-decide.js +239 -31
  16. package/dist/chat-messages.d.ts +5 -0
  17. package/dist/chat-messages.js +1 -1
  18. package/dist/chat-quiet.js +3 -2
  19. package/dist/chat-review.d.ts +4 -0
  20. package/dist/chat-review.js +13 -4
  21. package/dist/chat-terms.d.ts +9 -0
  22. package/dist/chat-terms.js +46 -0
  23. package/dist/contracts/console-api.d.ts +4 -2
  24. package/dist/contracts/console-api.js +7 -2
  25. package/dist/contracts/lead-tools.d.ts +30 -0
  26. package/dist/contracts/lead-tools.js +3 -0
  27. package/dist/contracts/youiyoux-request.d.ts +248 -0
  28. package/dist/contracts/youiyoux-request.js +152 -0
  29. package/dist/control-setup.d.ts +9 -0
  30. package/dist/control-setup.js +31 -4
  31. package/dist/control-ui.d.ts +7 -1
  32. package/dist/control-ui.js +3 -2
  33. package/dist/desktop-update.d.ts +1 -1
  34. package/dist/dispatch.d.ts +15 -0
  35. package/dist/dispatch.js +26 -4
  36. package/dist/first-run.d.ts +5 -2
  37. package/dist/first-run.js +13 -2
  38. package/dist/flow-cadence.d.ts +8 -2
  39. package/dist/flow-cadence.js +16 -3
  40. package/dist/flow-card-task.d.ts +9 -0
  41. package/dist/flow-card-task.js +15 -6
  42. package/dist/folder-look.d.ts +3 -0
  43. package/dist/folder-look.js +8 -4
  44. package/dist/guides.js +4 -3
  45. package/dist/integrations-ui.js +9 -4
  46. package/dist/integrations.d.ts +9 -1
  47. package/dist/integrations.js +43 -2
  48. package/dist/landed-cleanup.d.ts +4 -0
  49. package/dist/landed-cleanup.js +3 -2
  50. package/dist/lead-capabilities.js +2 -1
  51. package/dist/lead-cli.js +3 -1
  52. package/dist/lead-tools.d.ts +2 -0
  53. package/dist/lead-tools.js +30 -4
  54. package/dist/lead.d.ts +2 -0
  55. package/dist/lead.js +3 -3
  56. package/dist/model-catalog.d.ts +5 -0
  57. package/dist/model-catalog.js +11 -2
  58. package/dist/model-choices.d.ts +188 -0
  59. package/dist/model-choices.js +405 -0
  60. package/dist/model-names.d.ts +6 -0
  61. package/dist/model-names.js +34 -0
  62. package/dist/models-ui.d.ts +69 -5
  63. package/dist/models-ui.js +53 -10
  64. package/dist/needs-you.d.ts +8 -0
  65. package/dist/needs-you.js +8 -0
  66. package/dist/operate.d.ts +22 -2
  67. package/dist/operate.js +107 -41
  68. package/dist/project-activity.d.ts +20 -0
  69. package/dist/project-activity.js +23 -0
  70. package/dist/project-admission.d.ts +4 -1
  71. package/dist/project-admission.js +23 -2
  72. package/dist/project-cli.d.ts +1 -0
  73. package/dist/project-cli.js +36 -19
  74. package/dist/project-identity.d.ts +9 -2
  75. package/dist/project-identity.js +80 -33
  76. package/dist/project-knowledge.d.ts +9 -9
  77. package/dist/project-outcomes.d.ts +119 -0
  78. package/dist/project-outcomes.js +79 -0
  79. package/dist/result-actions.d.ts +4 -0
  80. package/dist/result-actions.js +41 -9
  81. package/dist/result-review.d.ts +9 -0
  82. package/dist/result-review.js +45 -5
  83. package/dist/serve.js +60 -13
  84. package/dist/server/chat.js +5 -4
  85. package/dist/server/chrome.d.ts +3 -0
  86. package/dist/server/chrome.js +5 -2
  87. package/dist/server/console.css +17 -2
  88. package/dist/server/flows.js +4 -0
  89. package/dist/server/http.d.ts +1 -0
  90. package/dist/server/http.js +6 -0
  91. package/dist/server/pages.js +53 -7
  92. package/dist/server/people-tokens.js +40 -20
  93. package/dist/server/render-chat.js +3 -2
  94. package/dist/server/render-pages.d.ts +12 -2
  95. package/dist/server/render-pages.js +36 -16
  96. package/dist/server/render-people.d.ts +14 -2
  97. package/dist/server/render-people.js +35 -12
  98. package/dist/server/render-results.d.ts +2 -0
  99. package/dist/server/render-results.js +20 -7
  100. package/dist/server/render-settings.js +1 -0
  101. package/dist/server/render-tasks.d.ts +1 -0
  102. package/dist/server/render-tasks.js +22 -19
  103. package/dist/server/request-context.d.ts +2 -0
  104. package/dist/server/route-table.js +9 -1
  105. package/dist/server/settings.d.ts +58 -1
  106. package/dist/server/settings.js +216 -70
  107. package/dist/server/signup-gate.d.ts +9 -0
  108. package/dist/server/signup-gate.js +40 -0
  109. package/dist/server/task-views.js +6 -0
  110. package/dist/server/tasks.js +54 -8
  111. package/dist/settings-kit-script.d.ts +21 -0
  112. package/dist/settings-kit-script.js +67 -0
  113. package/dist/settings-kit.d.ts +137 -0
  114. package/dist/settings-kit.js +186 -0
  115. package/dist/settings-proofs.d.ts +64 -0
  116. package/dist/settings-proofs.js +101 -0
  117. package/dist/setup-guide.js +4 -2
  118. package/dist/spend-report.d.ts +87 -0
  119. package/dist/spend-report.js +265 -0
  120. package/dist/spend-ui.d.ts +29 -8
  121. package/dist/spend-ui.js +74 -39
  122. package/dist/store.d.ts +46 -3
  123. package/dist/store.js +165 -6
  124. package/dist/task-sizing.d.ts +5 -3
  125. package/dist/task-sizing.js +4 -4
  126. package/dist/task-source.d.ts +13 -1
  127. package/dist/task-source.js +17 -1
  128. package/dist/telegram-lead.js +7 -5
  129. package/dist/telegram.js +1 -1
  130. package/dist/toolroll-update.js +2 -2
  131. package/dist/webhooks.d.ts +6 -0
  132. package/dist/webhooks.js +25 -0
  133. package/dist/work-index.d.ts +4 -0
  134. package/dist/work-index.js +32 -0
  135. package/dist/youiyoux-intake.d.ts +234 -0
  136. package/dist/youiyoux-intake.js +908 -0
  137. package/package.json +11 -8
@@ -1,13 +1,33 @@
1
+ import { verifyResultImage } from "./chat-evidence.js";
1
2
  /** Shared result-to-revision action for the result form and chat.
2
3
  * The caller authenticates; this service checks project access and the exact
3
4
  * displayed note batch, then uses the existing atomic revision/approval boundary.
4
5
  */
5
6
  import { createHash, randomBytes } from "node:crypto";
6
7
  import { EVIDENCE_CAPS, readVerifiedArtifact, writeEvidenceFile } from "./evidence.js";
7
- import { isRevisionFeedback, parseRevisionBatch, revisionSourceOf, commentSourceKey, REQUEST_TOKEN } from "./result-review.js";
8
+ import { parseFeedbackImage, sameFeedbackImage, isRevisionFeedback, parseRevisionBatch, revisionSourceOf, commentSourceKey, REQUEST_TOKEN } from "./result-review.js";
8
9
  import { validateNote, hasForbiddenControls } from "./decision.js";
9
10
  import { TASK_TEXT_LIMITS, validateTaskText } from "./task-text.js";
10
11
  import { modeFilingCoverage } from "./scope.js";
12
+ /** Re-prove image custody and current result at each write. No file paths supplied by callers. */
13
+ export function feedbackImageProblem(store, root, runId, image) {
14
+ if (image == null)
15
+ return null;
16
+ const parsed = parseFeedbackImage(image);
17
+ if (!parsed || image.run !== runId)
18
+ return "Choose a screenshot from this result. Your draft is unchanged.";
19
+ const run = store.getRun(runId);
20
+ const task = run === null ? null : store.externalIdFor(run.taskRef);
21
+ const repo = run === null ? null : store.refById(run.taskRef)?.repo;
22
+ if (!task || !repo)
23
+ return "This screenshot is no longer available. Your draft is unchanged.";
24
+ const family = store.taskFamilyOf(task, [repo], false);
25
+ const latest = store.runsFor(run.taskRef).find(one => ["builder", "repair", "scout"].includes(one.role) && one.outcome !== null);
26
+ if (family?.problem !== null || family.current.id !== task || latest?.id !== runId)
27
+ return "A newer result is current. Open it before commenting on a screenshot. Your draft is unchanged.";
28
+ const verified = verifyResultImage(store, root, [repo], { taskId: task, ...image });
29
+ return verified.ok ? null : "This screenshot is unavailable or changed. Restore it or remove it from your feedback. Your draft is unchanged.";
30
+ }
11
31
  /** One user action: add the typed change and seal exactly the displayed
12
32
  * saved notes. A refusal rolls back the note; a replay returns the same task. */
13
33
  export function requestResultChanges(store, evidenceRoot, input, now) {
@@ -15,8 +35,8 @@ export function requestResultChanges(store, evidenceRoot, input, now) {
15
35
  return store.transact(() => store.savepoint(() => {
16
36
  const fail = (status, message) => { throw new ChangeRequestRefusal(status, message); };
17
37
  if (!input.note.trim()) {
18
- if (input.path.trim() || input.line.trim())
19
- return fail(400, "Describe the change before attaching a file or line.");
38
+ if (input.path.trim() || input.line.trim() || input.image != null)
39
+ return fail(400, "Describe the change before attaching an image, file or line.");
20
40
  if (!input.batch)
21
41
  return fail(400, "Describe a change or use a saved note.");
22
42
  return createResultRevision(store, evidenceRoot, input, now);
@@ -43,13 +63,19 @@ export function requestResultChanges(store, evidenceRoot, input, now) {
43
63
  if (!artifact || !readVerifiedArtifact(evidenceRoot, artifact).ok)
44
64
  return fail(409, "The saved changes could not be verified. Restore the result before requesting changes.");
45
65
  const sourceKey = commentSourceKey(input.actor, input.request);
46
- let noteId = store.addDiffComment({ artifactId: artifact.id, runId: run.id, path, line, note: valid.note, author: input.actor, sourceKey }, now);
47
- if (noteId === null) {
48
- const earlier = store.diffCommentBySourceKey(sourceKey);
49
- if (!earlier || earlier.run !== run.id || earlier.path !== path || earlier.line !== line || earlier.note !== valid.note)
50
- return fail(409, "This submission already saved different feedback. Reopen the result and try again.");
51
- noteId = earlier.id;
66
+ const earlier = store.diffCommentBySourceKey(sourceKey);
67
+ if (earlier && (earlier.run !== run.id || earlier.path !== path || earlier.line !== line || earlier.note !== valid.note || !sameFeedbackImage(earlier.image, input.image)))
68
+ return fail(409, "This submission already saved different feedback. Reopen the result and try again.");
69
+ // An exact consumed replay only returns its existing revision. Every new
70
+ // use proves the image before insertion, so malformed targets refuse cleanly.
71
+ if (earlier?.consumedBy == null) {
72
+ const problem = feedbackImageProblem(store, evidenceRoot, run.id, input.image);
73
+ if (problem !== null)
74
+ return fail(409, problem);
52
75
  }
76
+ const noteId = earlier?.id ?? store.addDiffComment({ artifactId: artifact.id, runId: run.id, path, line, note: valid.note, author: input.actor, sourceKey, image: input.image ?? null }, now);
77
+ if (noteId === null)
78
+ return fail(409, "This feedback changed while saving. Your draft is unchanged; reopen the result.");
53
79
  const result = createResultRevision(store, evidenceRoot, { ...input, batch: [...new Set([...batch, noteId])].sort((a, b) => a - b).join(",") }, now);
54
80
  if (!result.ok)
55
81
  return fail(result.status, result.message);
@@ -141,6 +167,11 @@ export function createResultRevision(store, evidenceRoot, input, now) {
141
167
  }
142
168
  if (batch.some(one => !isRevisionFeedback(one)))
143
169
  return fail(409, "Review observations are not requested changes. Use Request change to add your feedback first.");
170
+ for (const one of batch) {
171
+ const problem = feedbackImageProblem(store, evidenceRoot, id, one.image);
172
+ if (problem !== null)
173
+ return fail(409, problem);
174
+ }
144
175
  const comments = batch;
145
176
  // Keep the subject recognizable, including when revising a revision.
146
177
  // Reserve the suffix before truncating, at whole grapheme boundaries.
@@ -180,6 +211,7 @@ export function createResultRevision(store, evidenceRoot, input, now) {
180
211
  note: one.note,
181
212
  author: one.author,
182
213
  createdAt: one.createdAt,
214
+ ...(one.image == null ? {} : { image: one.image }),
183
215
  })),
184
216
  };
185
217
  const briefBytes = Buffer.from(JSON.stringify(brief, null, 2), "utf8");
@@ -19,6 +19,14 @@
19
19
  * selected tab, and the reading position in this tab's sessionStorage,
20
20
  * keyed by the server-named account, the task, and the run. */
21
21
  import { type ResultFacts, type PublicationFacts, type DisplayStatus } from "./workspace-ui.js";
22
+ /** Optional context for feedback. The sealed diff remains the revision authority. */
23
+ export type FeedbackImage = {
24
+ run: number;
25
+ artifact: number;
26
+ sha256: string;
27
+ };
28
+ export declare function parseFeedbackImage(value: unknown): FeedbackImage | null | false;
29
+ export declare function sameFeedbackImage(a: FeedbackImage | null | undefined, b: FeedbackImage | null | undefined): boolean;
22
30
  /** Add re-read evidence health to the existing result/review projection.
23
31
  * This is presentation only: the recorded verdict and acceptance stay intact. */
24
32
  export declare function evidenceResultStatusOf(result: ResultFacts, publication: PublicationFacts, evidence: EvidenceHealth): DisplayStatus;
@@ -49,6 +57,7 @@ export type ResultScreenshot = {
49
57
  path: string;
50
58
  caption: string;
51
59
  artifactId: number;
60
+ sha256?: string;
52
61
  problem: string | null;
53
62
  };
54
63
  export type ResultEvidenceInput = {
@@ -19,6 +19,26 @@
19
19
  * selected tab, and the reading position in this tab's sessionStorage,
20
20
  * keyed by the server-named account, the task, and the run. */
21
21
  import { resultStatusOf } from "./workspace-ui.js";
22
+ export function parseFeedbackImage(value) {
23
+ if (value === undefined || value === null || value === "")
24
+ return null;
25
+ try {
26
+ const image = typeof value === "string" ? JSON.parse(value) : value;
27
+ if (image === null || typeof image !== "object" || Array.isArray(image)
28
+ || !Number.isSafeInteger(image.run) || image.run < 1
29
+ || !Number.isSafeInteger(image.artifact) || image.artifact < 1
30
+ || typeof image.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(image.sha256)
31
+ || Object.keys(image).some(key => !["run", "artifact", "sha256"].includes(key)))
32
+ return false;
33
+ return { run: image.run, artifact: image.artifact, sha256: image.sha256 };
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ export function sameFeedbackImage(a, b) {
40
+ return (a == null && b == null) || (a != null && b != null && a.run === b.run && a.artifact === b.artifact && a.sha256 === b.sha256);
41
+ }
22
42
  /** Add re-read evidence health to the existing result/review projection.
23
43
  * This is presentation only: the recorded verdict and acceptance stay intact. */
24
44
  export function evidenceResultStatusOf(result, publication, evidence) {
@@ -155,6 +175,8 @@ export function resultFactsFromHtml(html) {
155
175
  * page. Matched exactly — never a bare open redirect. */
156
176
  export function resultReturnTarget(raw, runId, tasks = []) {
157
177
  if (raw !== null && raw !== undefined) {
178
+ if (raw === `/r/${runId}?record=1`)
179
+ return raw;
158
180
  const review = /^\/review\?result=[A-Za-z0-9._~%-]{1,200}(?:&run=([0-9]{1,15}))?(?:&tab=(?:summary|changes|checks))?$/.exec(raw);
159
181
  if (review !== null && (review[1] === undefined || Number(review[1]) === runId))
160
182
  return raw;
@@ -299,6 +321,22 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
299
321
  document.addEventListener('click',function(ev){var link=ev.target&&ev.target.closest?ev.target.closest('a[href="#request-changes"]'):null;if(link)openForm();});
300
322
  if(form){
301
323
  var noteBox=form.querySelector('[name=note]'),pathBox=form.querySelector('[name=path]'),lineBox=form.querySelector('[name=line]'),pin=form.querySelector('details.result-pin');
324
+ var imageBox=form.querySelector('[name=image]'),imagePreview=form.querySelector('[data-feedback-image]');
325
+ function showImage(){
326
+ if(!imageBox||!imagePreview)return;
327
+ imagePreview.hidden=!imageBox.value;
328
+ var img=imagePreview.querySelector('img'),caption=imagePreview.querySelector('figcaption'),selected=null;
329
+ document.querySelectorAll('[data-comment-image]').forEach(function(button){if(button.getAttribute('data-comment-image')===imageBox.value)selected=button;});
330
+ img.hidden=!selected;
331
+ if(selected){var identity=JSON.parse(imageBox.value);img.src='/r/'+identity.run+'/evidence/'+identity.artifact;img.alt=selected.getAttribute('data-image-caption');caption.textContent=img.alt+' · Screenshot '+identity.artifact;}
332
+ else{img.removeAttribute('src');caption.textContent='This screenshot is unavailable. Restore it or remove it from your feedback.';}
333
+ }
334
+ document.addEventListener('click',function(event){
335
+ var button=event.target&&event.target.closest?event.target.closest('[data-comment-image],[data-remove-image]'):null;
336
+ if(!button||!imageBox)return;
337
+ event.preventDefault();imageBox.value=button.getAttribute('data-comment-image')||'';showImage();openForm();save();
338
+ if(noteBox){noteBox.dispatchEvent(new Event('input',{bubbles:true}));noteBox.scrollIntoView({block:'center',behavior:'instant'});noteBox.focus();}
339
+ });
302
340
  var limit=document.getElementById('comment-note-limit'),requestBox=form.querySelector('[name=request]');
303
341
  function updateActions(){
304
342
  var typed=!!(noteBox&&noteBox.value.trim()),batch=form.querySelector('[name=batch]');
@@ -333,7 +371,7 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
333
371
  // A fresh request identity: 16 random bytes as hex, the server's own shape.
334
372
  function mint(){var bytes=new Uint8Array(16);try{crypto.getRandomValues(bytes);}catch(e){for(var i=0;i<16;i++)bytes[i]=Math.floor(Math.random()*256);}
335
373
  var hex='';for(var j=0;j<16;j++)hex+=(bytes[j]<16?'0':'')+bytes[j].toString(16);return hex;}
336
- function payload(){return JSON.stringify([noteBox?noteBox.value:'',pathBox?pathBox.value:'',lineBox?lineBox.value:'']);}
374
+ function payload(){return JSON.stringify([noteBox?noteBox.value:'',pathBox?pathBox.value:'',lineBox?lineBox.value:'',imageBox?imageBox.value:'']);}
337
375
  // What the current token was last submitted with; null = never sent.
338
376
  var sent=null,bound=null;
339
377
  // The server refused this token for a different note or another result:
@@ -347,7 +385,7 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
347
385
  var focusNote=function(){setTimeout(function(){noteBox.focus({preventScroll:true});},0);};
348
386
  if(document.readyState==='complete')focusNote();else window.addEventListener('load',focusNote);
349
387
  }
350
- if(saved&&typeof saved.note==='string'&&saved.note.length<=2000){
388
+ if(saved&&typeof saved.note==='string'&&saved.note.length<=(noteBox?noteBox.maxLength:4000)){
351
389
  if(noteBox&&!noteBox.value)noteBox.value=saved.note;
352
390
  if(saved.note)openForm();
353
391
  if(pathBox&&!pathBox.value&&typeof saved.path==='string')pathBox.value=saved.path;
@@ -355,7 +393,9 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
355
393
  if(pin&&(pathBox&&pathBox.value||lineBox&&lineBox.value))pin.open=true;
356
394
  if(typeof saved.request==='string'&&/^[a-f0-9]{32}$/.test(saved.request)&&requestBox)requestBox.value=saved.request;
357
395
  if(typeof saved.sent==='string')sent=saved.sent;
358
- bound=JSON.stringify([saved.note,saved.path||'',saved.line||'']);
396
+ if(imageBox&&typeof saved.image==='string'&&saved.image.length<250)imageBox.value=saved.image;
397
+ showImage();
398
+ bound=JSON.stringify([saved.note,saved.path||'',saved.line||'',saved.image||'']);
359
399
  if(noteBox)noteBox.dispatchEvent(new Event('input'));
360
400
  }
361
401
  function save(){
@@ -365,8 +405,8 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
365
405
  var current=payload();
366
406
  if((bound!==null&&current!==bound)||(sent!==null&&current!==sent)){sent=null;if(requestBox)requestBox.value=mint();}
367
407
  bound=current;
368
- if(!note&&!path&&!line){write(draftKey,null);return;}
369
- write(draftKey,{note:note,path:path,line:line,request:requestBox?requestBox.value:'',sent:sent,at:Date.now()});
408
+ if(!note&&!path&&!line&&!(imageBox&&imageBox.value)){write(draftKey,null);return;}
409
+ write(draftKey,{note:note,path:path,line:line,image:imageBox?imageBox.value:'',request:requestBox?requestBox.value:'',sent:sent,at:Date.now()});
370
410
  if(!storage&&limit)limit.textContent='Draft stays on this page only. Browser storage is unavailable.';
371
411
  }
372
412
  form.addEventListener('input',save);
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); }
@@ -2727,3 +2730,15 @@ html.so-swap::view-transition-old(*):only-child { animation: so-card-out 160ms e
2727
2730
  html.so-swap::view-transition-group(*), html.so-swap::view-transition-new(*), html.so-swap::view-transition-old(*) { animation: none !important; }
2728
2731
  .worker.flash { animation: none; }
2729
2732
  }
2733
+
2734
+ /* Exact-image feedback shares the existing comment form. */
2735
+ .result-image { margin: 0; min-width: 0; }
2736
+ .result-image > button { margin-top: .5rem; min-height: 44px; white-space: nowrap; }
2737
+ .feedback-image { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem; margin: .5rem 0; min-width: 0; }
2738
+ .feedback-image[hidden], .feedback-image img[hidden] { display: none; }
2739
+ .feedback-image img { width: 5rem; max-height: 5rem; object-fit: contain; border: 1px solid var(--border); border-radius: var(--radius); }
2740
+ .feedback-image figcaption { flex: 1 1 12rem; overflow-wrap: anywhere; font-size: .875rem; }
2741
+ .feedback-image button { min-height: 44px; white-space: nowrap; }
2742
+
2743
+ .result-feedback-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
2744
+ .result-feedback-actions button { min-height: 44px; white-space: nowrap; }
@@ -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);