toolroll 0.9.86 → 0.9.88

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 (173) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/acp-check.d.ts +9 -0
  3. package/dist/acp-check.js +40 -28
  4. package/dist/agents-board.d.ts +12 -0
  5. package/dist/agents-board.js +26 -0
  6. package/dist/approval-rules-ui.d.ts +4 -1
  7. package/dist/approval-rules-ui.js +31 -15
  8. package/dist/assignment-ui.js +1 -1
  9. package/dist/backup-ui.d.ts +5 -4
  10. package/dist/backup-ui.js +65 -32
  11. package/dist/browser/terminal.css +1 -1
  12. package/dist/browser/terminal.js +57 -54
  13. package/dist/browser/workspace.css +2 -2
  14. package/dist/browser/workspace.js +35 -70
  15. package/dist/browser-crew.js +1 -1
  16. package/dist/browser-workspace.d.ts +329 -183
  17. package/dist/browser-workspace.js +10 -9
  18. package/dist/chat-controls.d.ts +1 -1
  19. package/dist/chat-controls.js +1 -1
  20. package/dist/chat-settings-page.d.ts +29 -9
  21. package/dist/chat-settings-page.js +59 -28
  22. package/dist/check-levels-ui.d.ts +2 -1
  23. package/dist/check-levels-ui.js +67 -58
  24. package/dist/contracts/cli.d.ts +43 -0
  25. package/dist/contracts/cli.js +4 -0
  26. package/dist/contracts/console-api.d.ts +1 -1
  27. package/dist/contracts/console-api.js +1 -1
  28. package/dist/credentials-ui.d.ts +5 -3
  29. package/dist/credentials-ui.js +58 -31
  30. package/dist/design/kit.css +722 -0
  31. package/dist/design/kit.d.ts +208 -0
  32. package/dist/design/kit.js +177 -0
  33. package/dist/design/tokens.css +31 -1
  34. package/dist/discord-settings.js +8 -4
  35. package/dist/export-ui.d.ts +6 -4
  36. package/dist/export-ui.js +18 -8
  37. package/dist/flow-changed.d.ts +14 -0
  38. package/dist/flow-changed.js +53 -0
  39. package/dist/flow-gallery-ui.d.ts +41 -4
  40. package/dist/flow-gallery-ui.js +95 -63
  41. package/dist/flow-starters-ui.d.ts +3 -9
  42. package/dist/flow-starters-ui.js +12 -23
  43. package/dist/flow-unattended-words.d.ts +20 -0
  44. package/dist/flow-unattended-words.js +24 -0
  45. package/dist/flow-unattended.d.ts +9 -5
  46. package/dist/flow-unattended.js +45 -23
  47. package/dist/flows-cli.js +7 -3
  48. package/dist/flows-ui.d.ts +12 -6
  49. package/dist/flows-ui.js +141 -30
  50. package/dist/integrations-ui.d.ts +6 -4
  51. package/dist/integrations-ui.js +34 -40
  52. package/dist/integrations.d.ts +2 -2
  53. package/dist/integrations.js +4 -4
  54. package/dist/kits-ui.d.ts +8 -10
  55. package/dist/kits-ui.js +29 -33
  56. package/dist/kits.js +1 -1
  57. package/dist/knowledge-ui.js +76 -19
  58. package/dist/ledger-view.d.ts +2 -0
  59. package/dist/ledger-view.js +41 -38
  60. package/dist/models-ui.d.ts +8 -3
  61. package/dist/models-ui.js +82 -59
  62. package/dist/monitoring-ui.d.ts +4 -2
  63. package/dist/monitoring-ui.js +44 -13
  64. package/dist/needs-you.d.ts +1 -1
  65. package/dist/needs-you.js +8 -0
  66. package/dist/operate-remote-arguments.js +1 -0
  67. package/dist/operate-remote.js +1 -1
  68. package/dist/operate.d.ts +1 -1
  69. package/dist/operate.js +38 -4
  70. package/dist/passwordless.d.ts +1 -1
  71. package/dist/passwordless.js +1 -1
  72. package/dist/people-audit-ui.d.ts +2 -1
  73. package/dist/people-audit-ui.js +25 -23
  74. package/dist/policy-ui.d.ts +3 -2
  75. package/dist/policy-ui.js +27 -21
  76. package/dist/policy.d.ts +3 -0
  77. package/dist/policy.js +14 -1
  78. package/dist/project-delete-ui.d.ts +4 -2
  79. package/dist/project-delete-ui.js +135 -126
  80. package/dist/project-knowledge.d.ts +10 -10
  81. package/dist/publish.d.ts +11 -0
  82. package/dist/publish.js +16 -3
  83. package/dist/pull-request-ui.d.ts +2 -1
  84. package/dist/pull-request-ui.js +39 -28
  85. package/dist/recipe-ui.d.ts +4 -2
  86. package/dist/recipe-ui.js +14 -4
  87. package/dist/release-adapters.d.ts +3 -0
  88. package/dist/release-adapters.js +22 -2
  89. package/dist/release-cli.js +81 -9
  90. package/dist/release.d.ts +23 -0
  91. package/dist/release.js +46 -4
  92. package/dist/releases.js +1 -1
  93. package/dist/request-budget-ui.d.ts +4 -2
  94. package/dist/request-budget-ui.js +30 -19
  95. package/dist/retention-ui.d.ts +4 -3
  96. package/dist/retention-ui.js +15 -12
  97. package/dist/scope.d.ts +3 -0
  98. package/dist/scope.js +6 -0
  99. package/dist/serve.d.ts +0 -1
  100. package/dist/serve.js +51 -7
  101. package/dist/server/agents-computers.d.ts +9 -0
  102. package/dist/server/agents-computers.js +67 -0
  103. package/dist/server/chat.js +16 -16
  104. package/dist/server/chrome.d.ts +1 -1
  105. package/dist/server/chrome.js +19 -9
  106. package/dist/server/console.css +24 -12
  107. package/dist/server/flows.js +64 -36
  108. package/dist/server/home.d.ts +35 -0
  109. package/dist/server/home.js +104 -0
  110. package/dist/server/http.d.ts +3 -0
  111. package/dist/server/http.js +5 -0
  112. package/dist/server/kit-standalone.d.ts +22 -0
  113. package/dist/server/kit-standalone.js +70 -0
  114. package/dist/server/kit.d.ts +419 -0
  115. package/dist/server/kit.js +369 -0
  116. package/dist/server/pages.d.ts +2 -1
  117. package/dist/server/pages.js +213 -174
  118. package/dist/server/people-tokens.js +26 -85
  119. package/dist/server/render-history.d.ts +51 -0
  120. package/dist/server/render-history.js +228 -0
  121. package/dist/server/render-new-task.d.ts +29 -0
  122. package/dist/server/render-new-task.js +96 -0
  123. package/dist/server/render-next.d.ts +63 -0
  124. package/dist/server/render-next.js +109 -0
  125. package/dist/server/render-pages.d.ts +22 -190
  126. package/dist/server/render-pages.js +123 -575
  127. package/dist/server/render-people.d.ts +24 -3
  128. package/dist/server/render-people.js +93 -72
  129. package/dist/server/render-projects.d.ts +103 -0
  130. package/dist/server/render-projects.js +247 -0
  131. package/dist/server/render-results.d.ts +5 -24
  132. package/dist/server/render-results.js +48 -185
  133. package/dist/server/render-settings.d.ts +5 -74
  134. package/dist/server/render-settings.js +102 -493
  135. package/dist/server/render-tasks.d.ts +35 -5
  136. package/dist/server/render-tasks.js +114 -85
  137. package/dist/server/route-table.js +20 -5
  138. package/dist/server/settings.js +276 -255
  139. package/dist/server/task-views.d.ts +8 -0
  140. package/dist/server/task-views.js +85 -2
  141. package/dist/server/tasks.d.ts +8 -0
  142. package/dist/server/tasks.js +95 -40
  143. package/dist/server/terminals.js +1 -1
  144. package/dist/server/welcome.d.ts +22 -0
  145. package/dist/server/welcome.js +69 -0
  146. package/dist/settings-groups-ui.d.ts +279 -0
  147. package/dist/settings-groups-ui.js +615 -0
  148. package/dist/settings-kit.d.ts +42 -6
  149. package/dist/settings-kit.js +69 -7
  150. package/dist/setup-guide.d.ts +2 -2
  151. package/dist/skills-ui.js +59 -7
  152. package/dist/slack-settings.js +12 -5
  153. package/dist/spend-ui.d.ts +45 -26
  154. package/dist/spend-ui.js +124 -56
  155. package/dist/sso-ui.d.ts +5 -2
  156. package/dist/sso-ui.js +63 -31
  157. package/dist/storage-ui.d.ts +3 -2
  158. package/dist/storage-ui.js +56 -50
  159. package/dist/store.d.ts +112 -3
  160. package/dist/store.js +292 -20
  161. package/dist/surface.js +5 -1
  162. package/dist/teams-settings.d.ts +2 -1
  163. package/dist/teams-settings.js +24 -41
  164. package/dist/telegram-settings.d.ts +0 -4
  165. package/dist/telegram-settings.js +52 -25
  166. package/dist/toolroll-update-ui.d.ts +8 -4
  167. package/dist/toolroll-update-ui.js +55 -45
  168. package/dist/tools-ui.d.ts +5 -3
  169. package/dist/tools-ui.js +107 -42
  170. package/dist/work-index.js +11 -7
  171. package/dist/workspace-ui.d.ts +1 -1
  172. package/dist/workspace-ui.js +7 -4
  173. package/package.json +1 -1
@@ -38,7 +38,7 @@ import { WORK_INDEX_MAX_LIMIT, workIndexPage } from "../work-index.js";
38
38
  import { html, htmlString, postForm, reauth } from "../html.js";
39
39
  import { requestContext } from "./request-context.js";
40
40
  import { refuse, screen, whenTime } from "./chrome.js";
41
- import { chatResultHref, chatReturnWithLatest, chatReturnWithSaid, projectChatHref, redirect, respond, safeChatReturn, safeReturn, taskChatHref, taskHref } from "./http.js";
41
+ import { chatResultHref, chatReturnWithLatest, chatRefusal, chatReturnWithSaid, projectChatHref, redirect, respond, safeChatReturn, safeReturn, taskChatHref, taskHref } from "./http.js";
42
42
  import { chatAckPage, chatPage, NOT_STARTED_SESSION, coordinatorProposalsSection, decisionsFor, leadAfterComposerHtml, leadChatVersion, leadMintCard, leadPage, leadThreadHtml, PHONE_CARD_FACT, TASK_COMPOSER_MODES, taskChatLiveRegion, teamProposalCardParts } from "./render-chat.js";
43
43
  import {} from "./render-pages.js";
44
44
  import { completionForm, hardBlockerOf, homePhaseWords, owedAcceptanceOf, resultPanelHtml } from "./render-results.js";
@@ -527,18 +527,18 @@ export function createChatHandlers(runtime) {
527
527
  return refuse(response, who, 403, "the sandbox never pushes");
528
528
  const password = body.get("token") ?? "";
529
529
  if (password === "" || !authenticateApprover(who, password).ok) {
530
- return redirect(response, `/settings?said=${encodeURIComponent("enrolling this device takes your password, typed again")}`);
530
+ return redirect(response, `/settings/alerts?said=${encodeURIComponent("enrolling this device takes your password, typed again")}#this-device`);
531
531
  }
532
532
  const endpoint = body.get("endpoint") ?? "";
533
533
  const p256dh = body.get("p256dh") ?? "";
534
534
  const auth = body.get("auth") ?? "";
535
535
  const checked = validatePushEndpoint(endpoint);
536
536
  if (!checked.ok)
537
- return redirect(response, `/settings?said=${encodeURIComponent(checked.problem)}`);
537
+ return redirect(response, `/settings/alerts?said=${encodeURIComponent(checked.problem)}#this-device`);
538
538
  const p256dhBytes = Buffer.from(p256dh, "base64url");
539
539
  const authBytes = Buffer.from(auth, "base64url");
540
540
  if (p256dhBytes.length !== 65 || p256dhBytes[0] !== 4 || authBytes.length !== 16) {
541
- return redirect(response, `/settings?said=${encodeURIComponent("that subscription's keys are not the shape a browser mints")}`);
541
+ return redirect(response, `/settings/alerts?said=${encodeURIComponent("that subscription's keys are not the shape a browser mints")}#this-device`);
542
542
  }
543
543
  const keys = loadOrCreateVapidKeys(dirname(options.telegramTokenFile ?? "."));
544
544
  const generation = store.approverGeneration(who.name) ?? 1;
@@ -552,9 +552,9 @@ export function createChatHandlers(runtime) {
552
552
  vapidFingerprint: keys.fingerprint,
553
553
  }, clock());
554
554
  if (!enrolled.ok) {
555
- return redirect(response, `/settings?said=${encodeURIComponent(enrolled.reason === "approver-cap" ? "five devices per person — remove one first" : "twenty devices per installation — remove one first")}`);
555
+ return redirect(response, `/settings/alerts?said=${encodeURIComponent(enrolled.reason === "approver-cap" ? "five devices per person — remove one first" : "twenty devices per installation — remove one first")}#this-device`);
556
556
  }
557
- return redirect(response, `/settings?said=${encodeURIComponent("this device now gets a buzz when the plane needs a person")}`);
557
+ return redirect(response, `/settings/alerts?said=${encodeURIComponent("this device now gets a buzz when the plane needs a person")}#this-device`);
558
558
  }
559
559
  async function pushRemove(ctx) {
560
560
  const { who, response, posted } = ctx;
@@ -564,9 +564,9 @@ export function createChatHandlers(runtime) {
564
564
  const id = Number(body.get("id") ?? "");
565
565
  const mine = store.listPushSubscriptions(who.name).find(one => one.id === id && one.retiredAt === null);
566
566
  if (mine === undefined)
567
- return redirect(response, `/settings?said=${encodeURIComponent("that device is not yours to remove, or it is already gone")}`);
567
+ return redirect(response, `/settings/alerts?said=${encodeURIComponent("that device is not yours to remove, or it is already gone")}#this-device`);
568
568
  store.retirePushSubscription(mine.id, `removed by ${who.name}`, clock());
569
- return redirect(response, `/settings?said=${encodeURIComponent("removed — that device stops receiving pushes")}`);
569
+ return redirect(response, `/settings/alerts?said=${encodeURIComponent("removed — that device stops receiving pushes")}#this-device`);
570
570
  }
571
571
  async function phoneDismiss(ctx) {
572
572
  const { who, response, now, posted } = ctx;
@@ -587,7 +587,7 @@ export function createChatHandlers(runtime) {
587
587
  const back = body.get("return") === "/settings/lead" ? "/settings/lead" : safeChatReturn(body.get("return"));
588
588
  const password = body.get("token") ?? "";
589
589
  if (password === "" || !authenticateApprover(who, password).ok) {
590
- return redirect(response, chatReturnWithSaid(back, "configuring chat spend takes your password, typed again"));
590
+ return redirect(response, chatRefusal(back, "configuring chat spend takes your password, typed again"));
591
591
  }
592
592
  if (body.get("off") === "1") {
593
593
  store.clearChatConfig();
@@ -604,12 +604,12 @@ export function createChatHandlers(runtime) {
604
604
  const weekly = Number(weeklyText);
605
605
  const daily = (body.get("daily-turns") ?? "").trim() === "" ? 50 : Number(body.get("daily-turns"));
606
606
  if (provider !== "anthropic-api" && provider !== "openrouter-api" && provider !== "claude-subscription" && provider !== "codex-subscription") {
607
- return redirect(response, chatReturnWithSaid(back, "pick a chat provider"));
607
+ return redirect(response, chatRefusal(back, "pick a chat provider"));
608
608
  }
609
609
  const subscription = isSubscriptionChatProvider(provider);
610
610
  const model = requestedModel === "" && subscription ? "default" : requestedModel;
611
611
  if (!validModelId(model)) {
612
- return redirect(response, chatReturnWithSaid(back, "the model id must be 1–128 letters, digits, dots, slashes, colons, underscores, or dashes"));
612
+ return redirect(response, chatRefusal(back, "the model id must be 1–128 letters, digits, dots, slashes, colons, underscores, or dashes"));
613
613
  }
614
614
  // The key, when pasted, is stored FIRST (0600 file, Telegram-token
615
615
  // precedent) so the catalog fetch below can already use it. It never
@@ -617,10 +617,10 @@ export function createChatHandlers(runtime) {
617
617
  const pastedKey = (body.get("key") ?? "").trim();
618
618
  if (pastedKey !== "") {
619
619
  if (subscription)
620
- return redirect(response, chatReturnWithSaid(back, "subscription chat uses the CLI's cached login — do not paste an API key"));
620
+ return redirect(response, chatRefusal(back, "subscription chat uses the CLI's cached login — do not paste an API key"));
621
621
  const stored = storeChatKey(provider, pastedKey);
622
622
  if (!stored.ok)
623
- return redirect(response, chatReturnWithSaid(back, stored.message));
623
+ return redirect(response, chatRefusal(back, stored.message));
624
624
  }
625
625
  // The pin: anthropic models come from the compiled table; openrouter
626
626
  // models come from OpenRouter's OWN catalog, priced by the authority
@@ -635,13 +635,13 @@ export function createChatHandlers(runtime) {
635
635
  pin = hit.price;
636
636
  }
637
637
  if (!subscription && pin === null) {
638
- return redirect(response, chatReturnWithSaid(back, provider === "openrouter-api" ? "that model is not in OpenRouter's catalog (or the catalog is unreachable) — chat cannot reserve spend it cannot bound" : "that model has no pinned price — chat cannot reserve spend it cannot bound"));
638
+ return redirect(response, chatRefusal(back, provider === "openrouter-api" ? "that model is not in OpenRouter's catalog (or the catalog is unreachable) — chat cannot reserve spend it cannot bound" : "that model has no pinned price — chat cannot reserve spend it cannot bound"));
639
639
  }
640
640
  if (!subscription && (!Number.isFinite(weekly) || weekly <= 0)) {
641
- return redirect(response, chatReturnWithSaid(back, "the weekly ceiling is a positive dollar amount — chat without one is unbounded, not configured"));
641
+ return redirect(response, chatRefusal(back, "the weekly ceiling is a positive dollar amount — chat without one is unbounded, not configured"));
642
642
  }
643
643
  if (!Number.isInteger(daily) || daily <= 0 || daily > 1_000) {
644
- return redirect(response, chatReturnWithSaid(back, "daily turns is a whole number between 1 and 1000"));
644
+ return redirect(response, chatRefusal(back, "daily turns is a whole number between 1 and 1000"));
645
645
  }
646
646
  store.setChatConfig({
647
647
  provider,
@@ -44,7 +44,7 @@ export declare const DEMO_BANNER_SHORT = "Demo: sample projects. Nothing calls a
44
44
  * React workspace, the pre-script fallback) reads these.
45
45
  *
46
46
  * The palette above, in light, dark and pinned themes, is Signal's generated
47
- * tokens.css (src/design, built by youi from the design-language repo); the
47
+ * tokens.css (src/design, generated by youiyoux from the toolroll System, src/design/system); the
48
48
  * console's own rules follow in console.css, shipped beside this module.
49
49
  * Their bytes are the served sheet's (WORKSPACE_STYLE hashes them). The React
50
50
  * workspace's stylesheet loads after this one and never redefines a token, so
@@ -34,6 +34,7 @@ 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
36
  import { crumbsHtml, SETTINGS_KIT_CSS } from "../settings-kit.js";
37
+ import { acts as kitActs, btn as kitBtn, doc as kitDoc, docHeader as kitDocHeader, notFound as kitNotFound, notice as kitNotice, outPage } from "./kit.js";
37
38
  import { SKILLS_CSS } from "../skills-ui.js";
38
39
  import { SPEND_CSS } from "../spend-ui.js";
39
40
  import { SSO_CSS } from "../sso-ui.js";
@@ -95,17 +96,23 @@ export function refuse(response, who, status, message, backHref = "/") {
95
96
  if (who === null || who.via === "bearer") {
96
97
  return respond(response, status, "text/plain; charset=utf-8", message);
97
98
  }
98
- const body = joinHtml([
99
- html `<h1>${status === 404 ? "Not found" : "Request refused"}</h1>`,
100
- // A missing page is plain news, not an error.
101
- status === 404 ? html `<p>${message}</p>` : html `<div class="problem">${message}</div>`,
102
- html `<p class="meta refusal-back"><a href="${backHref}">\u2190 Back</a></p>`,
103
- ], "\n");
99
+ // Kit pages (Wave E): a missing page is plain news, not an error, with two ways on; a refusal says what was refused
100
+ // where it happened and leads back.
101
+ const path = new URL(requestContext.getStore()?.returnTo ?? "/", "http://standing-orders.local").pathname;
102
+ const generic = /^there'?s no page at this address\.?$/i.test(message.replace("\u2019", "'"));
103
+ // The words are the caller's exactly (older callers wrote lower-case fragments; k-refusal capitalises the first letter).
104
+ const body = html `<div class="k-refusal">${status === 404
105
+ ? kitNotFound(path, generic ? {} : { title: message, text: "Home has what needs you; Tasks has every task." })
106
+ : kitDoc(kitDocHeader({ title: "Request refused", back: [backHref, "Back"] }), kitNotice(message), kitActs(kitBtn("Go back", { href: backHref })))}</div>`;
104
107
  // A signed-in browser keeps the workspace around it: the same navigation as every page.
105
108
  const framed = requestContext.getStore()?.refusal;
106
109
  if (framed !== undefined && who.via === "cookie")
107
110
  return framed(response, status, body);
108
- return page(response, status, shell("refused", body));
111
+ return page(response, status, shell(status === 404 ? "Not found" : "Request refused", outPage({
112
+ brand: BRAND_HTML, title: status === 404 ? "Not found" : "Request refused",
113
+ body: html `<div class="k-refusal">${kitNotice(status === 404 && generic ? `There\u2019s no page at ${path}.` : message)}</div>`,
114
+ foot: html `<a class="k-link" href="${status === 404 ? "/" : backHref}">${status === 404 ? "Go to Home" : "Go back"}</a>`,
115
+ })));
109
116
  }
110
117
  /** The demo's one-line promise, on every page. */
111
118
  export const DEMO_BANNER = `Demo: a scripted lead and sample projects. Nothing calls a model, reaches outside or spends. For your own project, run ${START_COMMAND} in its folder.`;
@@ -129,13 +136,16 @@ export const DEMO_BANNER_SHORT = "Demo: sample projects. Nothing calls a model o
129
136
  * React workspace, the pre-script fallback) reads these.
130
137
  *
131
138
  * The palette above, in light, dark and pinned themes, is Signal's generated
132
- * tokens.css (src/design, built by youi from the design-language repo); the
139
+ * tokens.css (src/design, generated by youiyoux from the toolroll System, src/design/system); the
133
140
  * console's own rules follow in console.css, shipped beside this module.
134
141
  * Their bytes are the served sheet's (WORKSPACE_STYLE hashes them). The React
135
142
  * workspace's stylesheet loads after this one and never redefines a token, so
136
143
  * a person's accent (accentHead) still wins over the generated default. */
137
144
  export const STYLE = readFileSync(join(import.meta.dirname, "..", "design", "tokens.css"), "utf8") +
138
- readFileSync(join(import.meta.dirname, "console.css"), "utf8");
145
+ readFileSync(join(import.meta.dirname, "console.css"), "utf8") +
146
+ // The revamp kit (src/design/KIT.md): k- classes only, after the console's rules, so the kit's markup from
147
+ // server pages (server/kit.ts) and React views (browser/kit/) looks the same everywhere.
148
+ readFileSync(join(import.meta.dirname, "..", "design", "kit.css"), "utf8");
139
149
  /** Appearance: a three-way segmented switch, one tap per choice. */
140
150
  export const THEME_CONTROLS_CSS = `.task-repo select{width:100%;min-height:2.75rem;font-size:1rem}.task-repo-add{margin:.35rem .1rem .5rem}.task-repo-add a{display:inline-flex;align-items:center;min-height:2.25rem}details.result-request-open.result-request-form>summary{border:0;background:transparent;padding:.5rem 0;min-height:2.75rem;font-weight:600;display:list-item;list-style:revert}details.result-request-open.result-request-form>summary::-webkit-details-marker{display:revert}form.js-autosave button[type=submit]{display:none}.provider-row{border-bottom:1px solid var(--so-line);padding:.35rem 0}.provider-row:first-of-type{border-top:1px solid var(--so-line)}.provider-head{display:flex;align-items:center;gap:.75rem;margin:.4rem 0 0}.provider-status{display:inline-flex;align-items:center;gap:.4rem;color:var(--so-muted);font-size:.875rem}.provider-status i{width:.5rem;height:.5rem;border-radius:50%;background:var(--so-muted)}.provider-status--ok i{background:var(--so-success)}.provider-status--warn i{background:var(--so-attention)}.provider-status--off i{background:transparent;border:1.5px solid var(--so-muted)}details.provider-manage>summary{cursor:pointer;color:var(--so-accent-text);font-size:.875rem;min-height:2.5rem;display:list-item;padding-block:.5rem}.card.props .row{display:grid;gap:.1rem;margin:0 0 .75rem}.card.props .row>.meta{display:block;font-size:.75rem}.card.props .row>.meta::first-letter{text-transform:uppercase}.card.props .row>.mono{font-family:var(--font-sans);font-size:.875rem}.card.props .row>.mono .seal{font-family:var(--font-mono);font-size:.8125rem}details.evidence-files{margin:1rem 0}details.evidence-files>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:600}details.evidence-files ul{list-style:none;margin:0;padding:0}details.evidence-files li{display:flex;justify-content:space-between;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--so-line)}.result-action .result-feedback-link{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;text-decoration:none}@media(hover:hover) and (pointer:fine){.result-action .result-feedback-link:hover{background:var(--so-raised)}}.so-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.verdict{margin:.5rem 0 .75rem}.verdict-chips{display:flex;flex-wrap:wrap;gap:.4rem;list-style:none;padding:0;margin:0}.verdict-chip{display:inline-flex;align-items:center;gap:.3rem;min-height:1.75rem;padding:.2rem .65rem;border-radius:999px;font-size:.8125rem;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.verdict-chip svg{width:.9rem;height:.9rem}.verdict-chip--success{background:var(--so-success-soft);color:var(--so-success)}.verdict-chip--danger{background:var(--so-danger-soft);color:var(--so-danger)}.verdict-chip--warning{background:var(--so-warning-soft);color:var(--so-warning)}.verdict-chip--info{background:var(--so-info-soft);color:var(--so-info)}.verdict-by{margin:.4rem 0 0}details.result-request-open{margin:.5rem 0}details.result-request-open>summary{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;cursor:pointer;list-style:none}details.result-request-open>summary::-webkit-details-marker{display:none}details.result-request-open[open]>summary{margin-bottom:.75rem}.settings-tiles{display:grid;gap:1.25rem;margin:0 0 2rem}.settings-tiles h2{margin:0 0 .5rem;font-size:.875rem;font-weight:600;color:var(--so-muted)}.settings-tiles section>div{display:grid;grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr));gap:.5rem}.settings-tiles a>span{display:grid;gap:.1rem;min-width:0}.settings-tiles .provider-status{font-weight:400;font-size:.8125rem}.settings-tiles a{display:flex;align-items:center;gap:.6rem;min-height:3rem;padding:.65rem .8rem;border:1px solid var(--so-line);border-radius:.625rem;background:var(--so-paper);color:var(--so-ink);text-decoration:none;font-weight:550;font-size:.875rem}@media(hover:hover) and (pointer:fine){.settings-tiles a:hover{border-color:var(--so-input-line);background:var(--so-raised)}}.settings-tiles a>svg{width:1.1rem;height:1.1rem;flex-shrink:0;color:var(--so-accent-text)}details.settings-more{margin:.25rem 0 1.25rem}details.settings-more>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:550}details.settings-more>summary .meta{font-weight:400;margin-left:.35rem}.settings-changed{margin-top:-.25rem}.appearance{margin:0 0 28px}.appearance h2{margin:0 0 10px}.theme-switch{display:inline-flex;flex-wrap:nowrap;max-width:100%;gap:4px;padding:4px;margin:0;border:1px solid var(--so-line);border-radius:10px;background:var(--so-raised)}.theme-switch .theme-choice,.so-native-region .theme-switch .theme-choice{flex:1 1 0;width:auto;white-space:nowrap;min-height:40px;padding:8px 16px;border:0;border-radius:7px;background:transparent;color:var(--so-muted);font:inherit;font-weight:550;box-shadow:none;cursor:pointer}@media(hover:hover) and (pointer:fine){.theme-switch .theme-choice:hover{color:var(--so-ink)}}.theme-switch .theme-choice[aria-pressed="true"]{background:var(--so-paper);color:var(--so-ink);box-shadow:0 1px 2px rgb(0 0 0 / .1)}.appearance .meta{margin:8px 0 0}@media(max-width:600px){.theme-switch .theme-choice{min-height:44px}}.update-notes{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;max-height:18rem;overflow:auto}`;
141
151
  /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
@@ -32,8 +32,8 @@
32
32
  }
33
33
  /* Signal's colour, shadow, radius, type and spacing tokens (--so-*) are
34
34
  * generated by youi into src/design/tokens.css, light and dark, and the
35
- * server prepends that sheet to this one (chrome.ts STYLE). Edit them in the
36
- * design-language repo, never here. The device decides the scheme unless the
35
+ * server prepends that sheet to this one (chrome.ts STYLE). Edit them in
36
+ * src/design/system/tokens and run youiyoux sync, never here. The device decides the scheme unless the
37
37
  * person pins one (html[data-theme], set from their cookie). What follows are
38
38
  * the console's older names: views onto those tokens, never a second ramp. */
39
39
  :root {
@@ -265,16 +265,26 @@
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
- :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
- color: var(--foreground); background: var(--card); min-height: 2.125rem;
271
- border: 1px solid var(--input); border-radius: calc(var(--radius) - 3px);
272
- transition: border-color .15s, box-shadow .15s;
273
- }
268
+ /* The kit's field family (src/design/KIT.md "Fields"): one filled surface, no outline, 12px radius; the label above
269
+ names the field and focus draws the ink ring. A transparent border keeps the edge in forced colours. Textareas
270
+ grow with their words (no resize grip); selects draw the kit's chevron. */
271
+ :where(input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], input[type=search], input[type=time], input:not([type]), textarea, select) {
272
+ width: 100%; margin: .35rem 0 0; padding: .5rem .75rem;
273
+ color: var(--foreground); background-color: var(--so-field); min-height: 2.25rem;
274
+ border: 1px solid transparent; border-radius: var(--so-radius-sheet); box-shadow: none;
275
+ transition: background-color .12s;
276
+ }
277
+ :where(textarea) { resize: none; field-sizing: content; min-height: 4.75rem; max-height: 22rem; overflow-y: auto; line-height: 1.5; }
278
+ :where(select:not([multiple])) {
279
+ appearance: none; -webkit-appearance: none; padding-right: 2.1rem; cursor: pointer;
280
+ background-image: linear-gradient(45deg, transparent 50%, var(--so-muted) 50%), linear-gradient(135deg, var(--so-muted) 50%, transparent 50%);
281
+ background-position: calc(100% - 17px) 52%, calc(100% - 12px) 52%; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat;
282
+ }
283
+ :where(input, textarea, select)::placeholder { color: var(--muted-foreground); opacity: 1; }
274
284
  /* The box above is in :where() so a surface's own rules (the workspace's 44px phone targets) win; the type keeps
275
285
  its old weight, so the phone's 16px rule still loses to it as before. */
276
286
  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); }
277
- @media (hover: hover) and (pointer: fine) { input:hover, textarea:hover, select:hover { border-color: var(--muted-foreground); } }
287
+ @media (hover: hover) and (pointer: fine) { :where(input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], input[type=search], input[type=time], input:not([type]), textarea, select):hover { background-color: color-mix(in srgb, var(--so-field) 94%, var(--so-ink)); } }
278
288
  input[type=number] { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
279
289
  input[type=radio], input[type=checkbox] { accent-color: var(--ring); }
280
290
  .permission-field { border: 0; padding: 0; margin: 1rem 0 0; min-width: 0; }
@@ -312,7 +322,8 @@
312
322
  min-height: 8.5rem; max-height: 18rem; margin: 0; padding: .9rem 1rem; resize: vertical;
313
323
  border: 0; background: transparent; box-shadow: none; font-size: 1.05rem; line-height: 1.55;
314
324
  }
315
- .task-prompt textarea:focus-visible { border: 0; box-shadow: none; }
325
+ .task-prompt textarea:focus-visible { border: 0; box-shadow: none; outline: none; }
326
+ .task-composer:focus-within { outline: 2px solid var(--so-ring); outline-offset: 2px; }
316
327
  @media (hover: hover) and (pointer: fine) { .task-prompt textarea:hover { border: 0; box-shadow: none; } }
317
328
  .task-repo { margin: .25rem .45rem .7rem; }
318
329
  .task-composer-footer { display: flex; align-items: center; gap: .5rem; padding: .25rem; }
@@ -361,7 +372,7 @@
361
372
  }
362
373
  input[type=password] { font-family: var(--font-mono); }
363
374
  input:focus-visible, textarea:focus-visible, select:focus-visible {
364
- outline: none; border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 25%, transparent);
375
+ outline: 2px solid var(--so-ring); outline-offset: 1px; box-shadow: none;
365
376
  }
366
377
  button:focus-visible, a:focus-visible, summary:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
367
378
 
@@ -2198,7 +2209,8 @@
2198
2209
  width: 100%; min-height: 3.5rem; max-height: 13rem; margin: 0; padding: .75rem .85rem;
2199
2210
  resize: vertical; border: 0; background: transparent; box-shadow: none; font-size: 1rem;
2200
2211
  }
2201
- .composer textarea:focus-visible { border: 0; box-shadow: none; }
2212
+ .composer textarea:focus-visible { border: 0; box-shadow: none; outline: none; }
2213
+ .composer:focus-within { outline: 2px solid var(--so-ring); outline-offset: 2px; }
2202
2214
  @media (hover: hover) and (pointer: fine) { .composer textarea:hover { border: 0; box-shadow: none; } }
2203
2215
  .composer button {
2204
2216
  flex: 0 0 2.75rem; width: 2.75rem; min-height: 2.75rem; padding: 0; border-radius: 999px;
@@ -11,7 +11,9 @@ import { LIMITS } from "../decision.js";
11
11
  import { run as execRun } from "../exec.js";
12
12
  import { setFlowSecret } from "../flow-actions.js";
13
13
  import { addCardToFlow, advanceFlows, cancelFlowCard, crossProjectProblem, decideFlowCard, flowDefinitionOf, moveCardInFlow } from "../flow-engine.js";
14
- import { galleryHtml, galleryUseHtml } from "../flow-gallery-ui.js";
14
+ import { galleryUseHtml, newFlowHtml } from "../flow-gallery-ui.js";
15
+ import { startersHtml } from "../flow-starters-ui.js";
16
+ import { starterOf, startersFor } from "../flow-starters.js";
15
17
  import { galleryAdmitsAnyone, galleryDefaults, galleryDiagram, galleryTemplateOf, galleryToolsOf, previewGallery, useGalleryTemplate } from "../flow-gallery.js";
16
18
  import { flowInsights } from "../flow-insights.js";
17
19
  import { assignFlowCard, commentOnFlowCard, watchFlowCard } from "../flow-people.js";
@@ -24,11 +26,11 @@ import { FLOW_TEMPLATES, FlowContractError, validateFlowDefinition, withZoneName
24
26
  import { revokeFlowBrowser, signFlowBrowser } from "../flow-browser.js";
25
27
  import { prepareUnattendedCandidates, parseUnattendedAsk, revokeUnattended, signUnattended, untilWords } from "../flow-unattended.js";
26
28
  import { keyStatus } from "../keys.js";
27
- import { kitPageHtml, kitsGalleryHtml } from "../kits-ui.js";
29
+ import { kitPageHtml, kitRowsHtml } from "../kits-ui.js";
28
30
  import { addKitGithubTrigger, addKitSample, kitOf, setUpKit } from "../kits.js";
29
31
  import { connectionsOf } from "../mcp-connect.js";
30
32
  import { authorizedProject, projectName, } from "../project.js";
31
- import { recipeAnswersFromForm, recipeDefinitionPreviewHtml, recipeEditorHtml, recipeFromForm, recipeLibraryHtml, recipeRunHtml, recipeScript, workflowPreviewHtml } from "../recipe-ui.js";
33
+ import { recipeAnswersFromForm, recipeDefinitionPreviewHtml, recipeEditorHtml, recipeFromForm, recipeRowsHtml, recipeRunHtml, recipeScript, workflowPreviewHtml } from "../recipe-ui.js";
32
34
  import { createWorkflowPreview, exportRecipe, findRecipe, importRecipe, launchWorkflow, prepareRecipeRun, RecipeError, savedRecipes, saveWorkflowRecipe, starterRecipes, workflowPreview } from "../recipes.js";
33
35
  import { isAlive as runnerAlive } from "../runner.js";
34
36
  import { createSubagentFrom, labelOf, nameOf, saveSoul, sendSubagentSummaries, setSubagentState, subagentSettings } from "../subagent-admin.js";
@@ -59,7 +61,7 @@ export function createFlowsHandlers(runtime) {
59
61
  const diagram = preview?.built.definition ?? galleryDiagram(template);
60
62
  const page = galleryUseHtml({ template, projects: projects.map(path => ({ path, name: projectName(path) })), repo, answers, name, preview, problem,
61
63
  csrf: who.via === "cookie" ? who.session.csrf : "", diagram, tools: preview?.tools ?? galleryToolsOf(template, diagram, connectionsOf(store, repo)), said });
62
- return sendScreen(response, problem !== null && preview === null && notice !== null ? 400 : 200, screen(template.name, html `<p><a href="/flows/new">New flow</a></p><h1>${template.name}</h1>${page}`, { chrome: chromeFor(repo, "flows") }));
64
+ return sendScreen(response, problem !== null && preview === null && notice !== null ? 400 : 200, screen(template.name, page, { chrome: chromeFor(repo, "flows") }));
63
65
  }
64
66
  // ---- path parameters and shared lookups ------------------------------------
65
67
  /** One segment of the path the route's pattern admitted ("/flows/1/cards/2/move" → 2 is "1", 4 is "2"). */
@@ -76,15 +78,50 @@ export function createFlowsHandlers(runtime) {
76
78
  };
77
79
  const sendJson = (response, status, payload) => respond(response, status, "application/json; charset=utf-8", JSON.stringify(payload));
78
80
  // ---- flows: reads ----------------------------------------------------------
79
- // Flows → New: the gallery, and each template's page.
81
+ // Flows → New: describe it to the lead, or start from a template (with the kits, recipes and starter flows as groups).
80
82
  async function newFlowGallery(ctx) {
81
83
  const { url, response, who, project } = ctx;
82
84
  const projects = consoleProjects();
83
- const canUse = who.via === "cookie" && who.role === "approver" && projects.length > 0;
84
- // The chosen project, else the one being looked at: its tools are what each card says is connected.
85
+ const approver = who.via === "cookie" && who.role === "approver";
86
+ const canUse = approver && projects.length > 0;
87
+ // The chosen project, else the one being looked at, else the first: its tools are what each row says is connected.
85
88
  const asked = url.searchParams.get("repo");
86
- const repo = asked !== null && projects.includes(asked) ? asked : project !== null && projects.includes(project) ? project : null;
87
- return sendScreen(response, 200, screen("New flow", html `<p><a href="/flows">Flows</a></p><h1>New flow</h1>${(galleryHtml({ repo, canUse, connections: repo === null ? null : connectionsOf(store, repo) }))}`, { chrome: chromeFor(repo ?? project, "flows") }));
89
+ const repo = asked !== null && projects.includes(asked) ? asked : project !== null && projects.includes(project) ? project : projects[0] ?? null;
90
+ // Describe what you want: the words go to the lead as a chat draft, which drafts the flow for the person to confirm.
91
+ const described = url.searchParams.get("describe");
92
+ let askError = null;
93
+ if (described !== null && canUse) {
94
+ const words = described.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").trim();
95
+ const named = projects.find(one => projectName(one) === url.searchParams.get("project")) ?? repo;
96
+ if (words !== "")
97
+ return redirect(response, `/chat?draft=${encodeURIComponent(`Make a flow${named === null ? "" : ` in ${projectName(named)}`}: ${words}`.slice(0, 4000))}`);
98
+ askError = "Describe what should happen first.";
99
+ }
100
+ // One-time work: the open project's recipes (recipe screens work in the open project).
101
+ let recipes;
102
+ try {
103
+ const revision = who.via === "cookie" ? who.session.projectRevision : 0;
104
+ const recent = project === null ? [] : store.handle.prepare("SELECT document,task_id,flow_id FROM workflow_preview WHERE repo=? AND (task_id IS NOT NULL OR flow_id IS NOT NULL) ORDER BY created_at DESC LIMIT 12").all(project).map(row => {
105
+ const d = importRecipe(String(row["document"]));
106
+ const task = row["task_id"] === null ? null : store.getTask(String(row["task_id"]));
107
+ const flow = row["flow_id"] === null ? null : store.getFlow(Number(row["flow_id"]));
108
+ const on = flow !== null && flow.state === "active" && store.flowTriggers(flow.id).some(one => one.kind === "schedule" && one.state === "active");
109
+ return { name: d.name, href: task !== null ? taskHref(task.id) : `/flows/${Number(row["flow_id"])}`, state: task?.state ?? (flow === null || flow.state !== "active" ? "Archived" : on ? "Scheduled" : "Paused") };
110
+ });
111
+ recipes = recipeRowsHtml(starterRecipes(), project === null ? [] : savedRecipes(store, who.name, project), project, project === null ? null : projectName(project), revision, recent);
112
+ }
113
+ catch (error) {
114
+ if (!(error instanceof RecipeError))
115
+ throw error;
116
+ recipes = html `<p class="k-notice" role="alert">${error.message}</p>`;
117
+ }
118
+ const starters = repo === null ? html `` : startersHtml({ repo, projectName: projectName(repo), starters: startersFor(store, repo), canSwitch: approver && !store.isDemo(),
119
+ suggested: starterOf(url.searchParams.get("starter") ?? "")?.id ?? null });
120
+ const said = url.searchParams.get("said"), problem = url.searchParams.get("problem");
121
+ const page = newFlowHtml({ repo, projects: projects.map(path => ({ path, name: projectName(path) })), canUse, group: url.searchParams.get("group") ?? "all",
122
+ connections: repo === null ? null : connectionsOf(store, repo), ask: { value: described ?? "", error: askError }, kits: kitRowsHtml(store, repo), recipes, starters,
123
+ said: said === null ? null : said.slice(0, 400), problem: problem === null ? null : problem.slice(0, 400) });
124
+ return sendScreen(response, 200, screen("New flow", page, { chrome: chromeFor(repo ?? project, "flows"), functional: { script: FLOW_IMPORT_SCRIPT } }));
88
125
  }
89
126
  async function galleryTemplatePage(ctx) {
90
127
  const { url, who, response, project } = ctx;
@@ -107,7 +144,7 @@ export function createFlowsHandlers(runtime) {
107
144
  const { url, who, response, project } = ctx;
108
145
  const projects = consoleProjects();
109
146
  const flows = store.listFlows(projects);
110
- return sendScreen(response, 200, screen("Flows", html `<h1>Flows</h1>${(flowsListHtml(store, flows, projects, who.via === "cookie" && who.role === "approver", url.searchParams.get("problem")))}`, { chrome: chromeFor(project, "flows"), functional: { script: FLOW_IMPORT_SCRIPT } }));
147
+ return sendScreen(response, 200, screen("Flows", flowsListHtml(store, flows, projects, who.via === "cookie" && who.role === "approver", url.searchParams.get("problem"), url.searchParams.get("project"), clock()), { chrome: chromeFor(project, "flows"), functional: { script: FLOW_IMPORT_SCRIPT } }));
111
148
  }
112
149
  async function flowInsightsRead(ctx) {
113
150
  const { url, response } = ctx;
@@ -157,10 +194,10 @@ export function createFlowsHandlers(runtime) {
157
194
  }
158
195
  // ---- starter kits: the gallery, and each kit's checklist in a project ------
159
196
  async function kitsGallery(ctx) {
160
- const { url, who, response, project } = ctx;
161
- const projects = consoleProjects();
162
- const canSetUp = who.via === "cookie" && who.role === "approver";
163
- return sendScreen(response, 200, screen("Starter kits", html `<h1>Starter kits</h1>${(kitsGalleryHtml(store, projects, projectName, canSetUp, { problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "flows") }));
197
+ const { url, response } = ctx;
198
+ // Starter kits live under New flow → With an agent now; a refusal sent here still shows there.
199
+ const problem = url.searchParams.get("problem");
200
+ return redirect(response, `/flows/new?group=kits${problem === null ? "" : `&problem=${encodeURIComponent(problem.slice(0, 400))}`}`);
164
201
  }
165
202
  async function kitPage(ctx) {
166
203
  const { url, who, response } = ctx;
@@ -169,8 +206,8 @@ export function createFlowsHandlers(runtime) {
169
206
  const kit = kitOf(pathPart(url, 2));
170
207
  const repo = url.searchParams.get("repo") ?? (projects.length === 1 ? projects[0] : "");
171
208
  if (kit === null || !projects.includes(repo))
172
- return redirect(response, "/kits");
173
- return sendScreen(response, 200, screen(kit.name, html `<h1>${kit.name}</h1>${(kitPageHtml(store, kit, repo, options.configDir ?? null, canSetUp, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(repo, "flows") }));
209
+ return redirect(response, "/flows/new?group=kits");
210
+ return sendScreen(response, 200, screen(kit.name, kitPageHtml(store, kit, repo, options.configDir ?? null, canSetUp, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }, projectName), { chrome: chromeFor(repo, "flows") }));
174
211
  }
175
212
  // ---- v92 / D5: the lead's subagents — the list, and one page per subagent ----
176
213
  async function subagentsPage(ctx) {
@@ -200,24 +237,9 @@ export function createFlowsHandlers(runtime) {
200
237
  response.writeHead(200, { ...SAFETY, "Content-Type": "text/markdown; charset=utf-8", "Content-Disposition": `attachment; filename="${mate.handle}.md"` });
201
238
  return void response.end(mate.soul);
202
239
  }
240
+ /** Recipes are New flow → One-time work now: the old address lands there. */
203
241
  async function recipeLibrary(ctx) {
204
- const { who, response, project } = ctx;
205
- try {
206
- const revision = who.via === "cookie" ? who.session.projectRevision : 0;
207
- const recent = project === null ? [] : store.handle.prepare("SELECT document,task_id,flow_id FROM workflow_preview WHERE repo=? AND (task_id IS NOT NULL OR flow_id IS NOT NULL) ORDER BY created_at DESC LIMIT 12").all(project).map(row => {
208
- const d = importRecipe(String(row["document"]));
209
- const task = row["task_id"] === null ? null : store.getTask(String(row["task_id"]));
210
- const flow = row["flow_id"] === null ? null : store.getFlow(Number(row["flow_id"]));
211
- const on = flow !== null && flow.state === "active" && store.flowTriggers(flow.id).some(one => one.kind === "schedule" && one.state === "active");
212
- return { name: d.name, href: task !== null ? taskHref(task.id) : `/flows/${Number(row["flow_id"])}`, state: task?.state ?? (flow === null || flow.state !== "active" ? "Archived" : on ? "Scheduled" : "Paused") };
213
- });
214
- return sendScreen(response, 200, screen("Workflow recipes", recipeLibraryHtml(starterRecipes(), project === null ? [] : savedRecipes(store, who.name, project), project, revision, recent), { chrome: chromeFor(project, "recipes"), functional: { script: recipeScript() } }));
215
- }
216
- catch (error) {
217
- if (error instanceof RecipeError)
218
- return refuse(response, who, error.status, error.message, "/recipes");
219
- throw error;
220
- }
242
+ return redirect(ctx.response, "/flows/new?group=recipes");
221
243
  }
222
244
  /** One recipe screen in the open project: the editor (start, new, edit, from a task), use, preview, export; any other address is no screen. */
223
245
  const recipeScreen = (shown) => async (ctx) => {
@@ -537,7 +559,7 @@ export function createFlowsHandlers(runtime) {
537
559
  // What was previewed: the file and every value it resolved to (a default the first preview filled in included).
538
560
  const previewedOf = (plan) => createHash("sha256").update(`${document}\0${JSON.stringify(plan?.values ?? given)}`).digest("hex").slice(0, 32);
539
561
  const csrf = who.via === "cookie" ? who.session.csrf : "";
540
- const page = (plan, problem, status) => sendScreen(response, status, screen(`Import ${file.name}`, html `<p><a href="/flows">Flows</a></p><h1>Import ${file.name}</h1>${(flowImportHtml({ plan, file, document, repo, csrf, values: given, previewed: previewedOf(plan), problem }))}`, { chrome: chromeFor(repo, "flows") }));
562
+ const page = (plan, problem, status) => sendScreen(response, status, screen(`Import ${file.name}`, flowImportHtml({ plan, file, document, repo, csrf, values: given, previewed: previewedOf(plan), problem }), { chrome: chromeFor(repo, "flows") }));
541
563
  let plan;
542
564
  try {
543
565
  plan = planFlowImport(store, repo, file, given, who.name);
@@ -614,8 +636,14 @@ export function createFlowsHandlers(runtime) {
614
636
  }
615
637
  if (action === "scripts") {
616
638
  // The project's script library, reached from any of its flows.
617
- if (body.get("approve") === "yes")
618
- return store.approveFlowScript(flow.repo, body.get("name") ?? "") ? settle("Approved. Zones that run it go on.") : answer(404, { ok: false, said: "That script isn't waiting for approval." });
639
+ if (body.get("approve") === "yes") {
640
+ // The approval covers the version and content the panel showed (its version and digest ride back).
641
+ const version = Number(body.get("version")), digest = body.get("digest") ?? "";
642
+ const approved = Number.isSafeInteger(version) && version > 0 && digest !== "" ? store.approveFlowScript(flow.repo, body.get("name") ?? "", { version, digest }) : "changed";
643
+ return approved === "approved" ? settle("Approved. Zones that run it go on.")
644
+ : approved === "changed" ? answer(409, { ok: false, said: "This script changed since you read it. Read the new script, then approve it. Nothing was approved." })
645
+ : answer(404, { ok: false, said: "That script isn't waiting for approval." });
646
+ }
619
647
  if (body.get("remove") === "yes")
620
648
  return store.removeFlowScript(flow.repo, body.get("name") ?? "", who.name, now) ? settle("Script removed. Zones that ran it wait until it's back.") : answer(404, { ok: false, said: "There's no script by that name." });
621
649
  const saved = saveScript(store, flow.repo, { name: body.get("name"), about: body.get("about"), body: body.get("body"), timeoutMinutes: body.get("timeoutMinutes"), language: body.get("language"), file: body.get("file") }, who.name, now);
@@ -0,0 +1,35 @@
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), what waits on this person grouped by what it
4
+ * asks, what is running, 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).
6
+ */
7
+ import type { BrowserHomeView } from "../browser-workspace.js";
8
+ import type { Store } from "../store.js";
9
+ import type { WorkSummaryAccess } from "../work-summary.js";
10
+ /** How many waiting rows Home lists; the rest open in Tasks. */
11
+ export declare const HOME_NEEDS_SHOWN = 30;
12
+ export type HomeFacts = {
13
+ store: Store;
14
+ now: Date;
15
+ access: WorkSummaryAccess;
16
+ evidenceRoot?: string;
17
+ /** The project in view (already admitted), or null for every project. */
18
+ project: string | null;
19
+ /** Every admitted project, for the scope links. */
20
+ projects: readonly {
21
+ name: string;
22
+ path: string;
23
+ }[];
24
+ /** Chat is allowed for this person here (the lead's message box). */
25
+ chat: boolean;
26
+ /** This person can answer an agent's question (an approver in a browser session). */
27
+ canAnswer: boolean;
28
+ };
29
+ /** "7 need you across 2 projects." — the sentence and the figures read the same totals. */
30
+ export declare function homeSentence(needs: number, perProject: readonly {
31
+ name: string;
32
+ needs: number;
33
+ }[], scope: string | null, any: boolean): string;
34
+ /** Home's view for one person and scope, from two reads of the work index (what waits, what runs). */
35
+ export declare function homeView(facts: HomeFacts): BrowserHomeView;
@@ -0,0 +1,104 @@
1
+ import { browserWorkActionHref } from "../browser-crew.js";
2
+ import { ASK_LABEL } from "../needs-you.js";
3
+ import { projectName } from "../project.js";
4
+ import { workIndexPage } from "../work-index.js";
5
+ import { relativeAge } from "./chrome.js";
6
+ import { taskHref } from "./http.js";
7
+ const TONE = { attention: "needs", ready: "needs", live: "live", done: "done", problem: "failed", muted: "", neutral: "" };
8
+ const ASKS = ["decide", "review", "unblock"];
9
+ const ASK_WORD = { decide: "Decision", review: "Result", unblock: "Blocked" };
10
+ /** How many waiting rows Home lists; the rest open in Tasks. */
11
+ export const HOME_NEEDS_SHOWN = 30;
12
+ const HOME_RUNNING_SHOWN = 8;
13
+ /** "7 need you across 2 projects." — the sentence and the figures read the same totals. */
14
+ export function homeSentence(needs, perProject, scope, any) {
15
+ if (!any)
16
+ return "No tasks yet.";
17
+ if (needs === 0)
18
+ return scope === null ? "Nothing needs you." : `Nothing needs you in ${scope}.`;
19
+ const verb = needs === 1 ? "needs" : "need";
20
+ if (scope !== null)
21
+ return `${needs} ${verb} you in ${scope}.`;
22
+ const waiting = perProject.filter(one => one.needs > 0);
23
+ return waiting.length === 1 ? `${needs} ${verb} you in ${waiting[0].name}.` : `${needs} ${verb} you across ${waiting.length} projects.`;
24
+ }
25
+ function rowOf(store, item, now, multi, canAnswer) {
26
+ const action = item.primaryAction;
27
+ const href = action?.code === "open-result" && action.target.runId != null
28
+ ? `/review?result=${encodeURIComponent(action.target.taskId)}&run=${action.target.runId}` : browserWorkActionHref(item);
29
+ const decisionId = action?.target.decisionId ?? null;
30
+ const decision = decisionId === null ? null : store.getDecision(decisionId);
31
+ const open = decision !== null && decision.state === "open" && decision.supersededReason === null;
32
+ // An irreversible option is armed behind a confirmation on its own page; Home answers only what one click may.
33
+ const question = open && canAnswer && decision.options.every(option => option.reversible) ? {
34
+ id: decision.id, text: decision.question, recap: decision.recap,
35
+ options: decision.options.map(option => ({ id: option.id, label: option.label, consequence: option.consequence, recommended: option.id === decision.recommendation })),
36
+ } : null;
37
+ const waits = item.status.views.includes("needs-you");
38
+ // The specific ask, never the group's own words again: "Needs you" under Unblock reads "Blocked".
39
+ const word = waits ? (open ? "Question" : item.chip ?? (item.status.label === "Needs you" && item.ask !== null ? ASK_WORD[item.ask] : item.status.label)) : item.status.label;
40
+ const tone = waits ? "ask" : TONE[item.status.tone];
41
+ const line = waits
42
+ ? (open ? decision.question : item.status.detail !== item.status.label ? item.status.detail : null)
43
+ : item.progress ?? (item.status.detail !== item.status.label ? item.status.detail : null);
44
+ return {
45
+ id: item.rootId, title: item.title, href: taskHref(item.rootId),
46
+ project: multi || item.repo === null ? (item.repo === null ? "Unplaced" : projectName(item.repo)) : null,
47
+ age: relativeAge(item.updatedAt, now), chip: { word, tone }, line,
48
+ action: href === null || action === null ? null : { label: action.label, href }, question,
49
+ };
50
+ }
51
+ /** Home's view for one person and scope, from two reads of the work index (what waits, what runs). */
52
+ export function homeView(facts) {
53
+ const { store, now, access, project } = facts;
54
+ const read = (view, limit) => workIndexPage(store, now, access, { view, limit, project, ...(facts.evidenceRoot === undefined ? {} : { root: facts.evidenceRoot }) });
55
+ const needs = read("needs-you", HOME_NEEDS_SHOWN);
56
+ const running = read("running", HOME_RUNNING_SHOWN);
57
+ const totals = needs.totals;
58
+ // Queued: the Tasks list's own Queued count (the same predicate its view=queued filter lists).
59
+ const queued = totals.queued;
60
+ // Every project is said by name (all=1): /work alone would narrow to the session's open project.
61
+ const scopeQuery = project === null ? "all=1&" : `project=${encodeURIComponent(project)}&`;
62
+ const counts = [
63
+ { key: "needs", label: "Needs you", value: totals["needs-you"], href: `/work?${scopeQuery}view=needs-you` },
64
+ { key: "running", label: "Running", value: totals.running, href: `/work?${scopeQuery}view=running` },
65
+ { key: "queued", label: "Queued", value: queued, href: `/work?${scopeQuery}view=queued` },
66
+ { key: "finished", label: "Finished", value: totals.completed, href: `/work?${scopeQuery}view=completed` },
67
+ ];
68
+ const multi = project === null && new Set([...needs.items, ...running.items].map(one => one.repo ?? "")).size > 1;
69
+ const groups = [];
70
+ for (const ask of ASKS) {
71
+ const rows = needs.items.filter(one => one.ask === ask).map(one => rowOf(store, one, now, multi, facts.canAnswer));
72
+ if (rows.length > 0)
73
+ groups.push({ key: ask, label: ASK_LABEL[ask], rows });
74
+ }
75
+ // A waiting row whose ask the index couldn't name still waits on this person: it goes under Decide, never lost.
76
+ const unnamed = needs.items.filter(one => one.ask === null).map(one => rowOf(store, one, now, multi, facts.canAnswer));
77
+ if (unnamed.length > 0) {
78
+ const decide = groups.find(one => one.key === "decide");
79
+ if (decide)
80
+ decide.rows.push(...unnamed);
81
+ else
82
+ groups.unshift({ key: "decide", label: ASK_LABEL.decide, rows: unnamed });
83
+ }
84
+ const live = running.items.filter(one => !one.status.views.includes("needs-you")).map(one => rowOf(store, one, now, multi, facts.canAnswer));
85
+ if (live.length > 0)
86
+ groups.push({ key: "running", label: "Running", rows: live });
87
+ const perProject = needs.projects.map(one => ({ name: one.repo === null ? "Unplaced" : projectName(one.repo), needs: one.totals["needs-you"] }));
88
+ const any = totals.all > 0 || totals.completed > 0 || (totals.archived ?? 0) > 0;
89
+ const shown = groups.filter(one => one.key !== "running").reduce((sum, one) => sum + one.rows.length, 0);
90
+ return {
91
+ kind: "home",
92
+ say: homeSentence(totals["needs-you"], perProject, project === null ? null : projectName(project), any),
93
+ project,
94
+ scopes: facts.projects.length < 2 ? null : [
95
+ { key: "", label: "All projects", href: "/", active: project === null },
96
+ ...facts.projects.map(one => ({ key: one.path, label: one.name, href: `/?project=${encodeURIComponent(one.path)}`, active: one.path === project })),
97
+ ],
98
+ counts, groups,
99
+ more: totals["needs-you"] > shown ? { count: totals["needs-you"] - shown, href: counts[0].href } : null,
100
+ newTask: "/tasks/new",
101
+ ask: facts.chat ? { project, placeholder: project === null ? "Ask the lead about your projects…" : `Ask the lead about ${projectName(project)}…` } : null,
102
+ empty: any ? null : { title: "No tasks yet.", text: "Describe a change in a sentence; an agent writes a plan for you to approve." },
103
+ };
104
+ }
@@ -304,3 +304,6 @@ export declare const reviewHref: (taskId: string, runId?: number | null) => stri
304
304
  * has room and as a dedicated view with Back to chat when it does not. */
305
305
  export declare const chatResultHref: (taskId: string, runId: number, tab?: ResultTab) => string;
306
306
  export declare function form(request: IncomingMessage, cap?: number): Promise<URLSearchParams>;
307
+ /** A refused chat setting (POST /chat/config): back on the lead's settings it is a ?problem=, which the page's section
308
+ * shows in place with the edits kept; elsewhere it stays the chat's said line. */
309
+ export declare function chatRefusal(back: string, words: string): string;