@k2b/cloud 0.39.0 → 0.41.0

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 (237) hide show
  1. package/README.md +8 -0
  2. package/package.json +11 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +217 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +49 -18
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/heartbeat.ts +203 -14
  12. package/src/_internal/registry.ts +28 -1
  13. package/src/_internal/runtime-watcher.ts +6 -0
  14. package/src/_internal/setup-lock.ts +103 -0
  15. package/src/_internal/static-assets.ts +52 -3
  16. package/src/_internal/status-preserving-ssr.ts +2 -0
  17. package/src/access/PermissionEditor.tsx +2 -2
  18. package/src/account/EntitySearch.tsx +1 -1
  19. package/src/ai/action-review.ts +101 -0
  20. package/src/ai/background-review.ts +72 -0
  21. package/src/ai/capabilities.ts +29 -11
  22. package/src/ai/capability-execution.ts +76 -43
  23. package/src/ai/capability-result.ts +95 -0
  24. package/src/ai/chat/action-card.ts +464 -0
  25. package/src/ai/chat/action-messages.ts +129 -0
  26. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  27. package/src/ai/chat/action-question.fixtures.ts +733 -0
  28. package/src/ai/chat/action-question.tsx +680 -0
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/blocks.tsx +19 -25
  31. package/src/ai/chat/message-actions.tsx +6 -0
  32. package/src/ai/chat/tool-action-words.ts +31 -0
  33. package/src/ai/chat/tool-action.tsx +456 -0
  34. package/src/ai/chat/turn-view.tsx +10 -160
  35. package/src/ai/chat/view-block.tsx +15 -5
  36. package/src/ai/chat-task-contracts.ts +4 -1
  37. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  38. package/src/ai/chat-task-reasons.ts +1 -1
  39. package/src/ai/chat-tasks.ts +55 -25
  40. package/src/ai/client/controller.ts +59 -0
  41. package/src/ai/default-tools.ts +9 -1
  42. package/src/ai/executor.ts +198 -39
  43. package/src/ai/firecrawl-tools.ts +15 -2
  44. package/src/ai/index.ts +3 -0
  45. package/src/ai/migrate.ts +26 -2
  46. package/src/ai/open-questions.ts +172 -0
  47. package/src/ai/projects-routes.ts +3 -0
  48. package/src/ai/protocol.ts +14 -2
  49. package/src/ai/question-text.ts +8 -0
  50. package/src/ai/recent-resources.ts +240 -0
  51. package/src/ai/recent-sent-refs.ts +38 -0
  52. package/src/ai/routes.ts +195 -5
  53. package/src/ai/runtime.ts +1 -0
  54. package/src/ai/skills-routes.ts +3 -0
  55. package/src/ai/skills.ts +10 -1
  56. package/src/ai/store.ts +245 -24
  57. package/src/ai/testing.ts +36 -0
  58. package/src/ai/tool-audit.ts +8 -0
  59. package/src/ai/tool-sentences.ts +152 -34
  60. package/src/ai/tool-wording.ts +36 -8
  61. package/src/ai/tools.ts +8 -3
  62. package/src/ai/types.ts +43 -4
  63. package/src/api/accounts-entities.ts +2 -1
  64. package/src/api/admin-ai-projects.ts +2 -0
  65. package/src/api/admin-ai-skills.ts +2 -0
  66. package/src/api/admin-ai-usage.ts +2 -0
  67. package/src/api/admin-geoip.ts +80 -0
  68. package/src/api/admin-identity.ts +3 -0
  69. package/src/api/admin-lifecycle.ts +3 -0
  70. package/src/api/admin-outgoing-mail.ts +2 -0
  71. package/src/api/app-approval-actions.ts +31 -7
  72. package/src/api/app-approval.ts +46 -5
  73. package/src/api/approvals-loader.ts +23 -1
  74. package/src/api/approvals.ts +2 -0
  75. package/src/api/auth/schemas.ts +3 -0
  76. package/src/api/auth.ts +67 -12
  77. package/src/api/capabilities.ts +44 -11
  78. package/src/api/capability-streams.ts +12 -0
  79. package/src/api/help.ts +4 -28
  80. package/src/api/index.ts +6 -1
  81. package/src/api/lasting-access-messages.ts +17 -0
  82. package/src/api/mcp.ts +30 -3
  83. package/src/api/me-panel-consent.ts +71 -0
  84. package/src/api/me.ts +159 -36
  85. package/src/api/search.ts +3 -1
  86. package/src/api/sync-ops.ts +2 -0
  87. package/src/api/widgets.ts +3 -0
  88. package/src/approvals/Approvals.tsx +166 -262
  89. package/src/approvals/approval-card.ts +68 -0
  90. package/src/approvals/messages.ts +12 -24
  91. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  92. package/src/browser/app-approval.ts +54 -5
  93. package/src/browser/commands.ts +22 -6
  94. package/src/browser/composer-catalog.ts +45 -3
  95. package/src/browser/composer-insert.ts +10 -0
  96. package/src/browser/composer-open-messages.ts +17 -0
  97. package/src/browser/composer-open.ts +110 -0
  98. package/src/browser/discoverable-command.ts +6 -0
  99. package/src/browser/hints.ts +6 -0
  100. package/src/browser/live-connection.ts +26 -2
  101. package/src/browser/navigation-apps.ts +7 -0
  102. package/src/browser/panel-context-bridge.ts +30 -0
  103. package/src/browser/panel-context-receiver.ts +53 -0
  104. package/src/browser/panel-menu.tsx +56 -0
  105. package/src/browser/panel-messages.ts +11 -0
  106. package/src/browser/panel-shortcuts.ts +31 -0
  107. package/src/browser/panel-slot.ts +412 -10
  108. package/src/browser/panel.ts +37 -0
  109. package/src/browser/search-commands.ts +6 -3
  110. package/src/capabilities/client.ts +8 -2
  111. package/src/capabilities/reviews.ts +205 -0
  112. package/src/capabilities/server.ts +10 -3
  113. package/src/capabilities/testing.ts +47 -0
  114. package/src/capabilities/types.ts +2 -2
  115. package/src/cli/account.ts +13 -3
  116. package/src/cli/admin/geoip.ts +115 -0
  117. package/src/cli/admin/index.ts +3 -0
  118. package/src/contracts/app-approval.ts +123 -16
  119. package/src/contracts/approvals.ts +2 -2
  120. package/src/contracts/capabilities.ts +410 -18
  121. package/src/contracts/geoip.ts +42 -0
  122. package/src/contracts/index.ts +3 -0
  123. package/src/contracts/panel-context-plain.ts +99 -0
  124. package/src/contracts/panel-context.ts +93 -0
  125. package/src/contracts/seen-hints.ts +35 -0
  126. package/src/contracts/shared.ts +3 -0
  127. package/src/server/client-ip.ts +5 -0
  128. package/src/server/help-payload.ts +38 -0
  129. package/src/server/middleware/auth.ts +13 -1
  130. package/src/server/middleware/middleware.ts +9 -0
  131. package/src/server/middleware/no-transform.ts +19 -0
  132. package/src/server/middleware/rate-limit.ts +4 -6
  133. package/src/server/request-context/address.ts +45 -0
  134. package/src/server/request-context/geoip.ts +380 -0
  135. package/src/server/request-context/index.ts +33 -0
  136. package/src/server/request-context/user-agent.ts +95 -0
  137. package/src/services/accounts/app.ts +23 -3
  138. package/src/services/accounts/users.ts +12 -1
  139. package/src/services/app-approval.ts +382 -59
  140. package/src/services/approvals/device.ts +49 -31
  141. package/src/services/approvals/index.ts +7 -5
  142. package/src/services/approvals/list.ts +27 -6
  143. package/src/services/approvals/question.ts +68 -0
  144. package/src/services/audit/index.ts +1 -0
  145. package/src/services/auth-flows/ipa.ts +39 -2
  146. package/src/services/auth-flows/notification-sender.ts +3 -0
  147. package/src/services/auth-flows/password-reset.ts +32 -6
  148. package/src/services/geoip/index.ts +164 -0
  149. package/src/services/geoip/store.ts +30 -0
  150. package/src/services/geoip/upload.ts +150 -0
  151. package/src/services/identity/routes.ts +5 -1
  152. package/src/services/index.ts +13 -0
  153. package/src/services/known-browsers.ts +312 -0
  154. package/src/services/login-address-store.ts +51 -0
  155. package/src/services/login-context.ts +118 -0
  156. package/src/services/mandates/index.ts +3 -0
  157. package/src/services/mandates/policy.ts +15 -3
  158. package/src/services/notifications/email-frame.ts +1 -1
  159. package/src/services/oauth-tokens.ts +16 -2
  160. package/src/services/panel-context-consent.ts +58 -0
  161. package/src/services/pdf/markdown.ts +160 -2
  162. package/src/services/service-account-credentials.ts +21 -0
  163. package/src/services/session/index.ts +296 -41
  164. package/src/services/session/recent.ts +13 -0
  165. package/src/services/webauthn.ts +12 -0
  166. package/src/shared/email-html.ts +2 -0
  167. package/src/shared/help.ts +3 -0
  168. package/src/shared-text/cursor.ts +22 -0
  169. package/src/shared-text/documents.ts +142 -0
  170. package/src/shared-text/events.ts +43 -0
  171. package/src/shared-text/index.ts +12 -0
  172. package/src/shared-text/log.ts +48 -0
  173. package/src/shared-text/text-diff.ts +23 -0
  174. package/src/shared-text/types.ts +41 -0
  175. package/src/ssr/AdminLayout.tsx +2 -0
  176. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  177. package/src/ssr/CloudShell.island.tsx +10 -2
  178. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  179. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  180. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  181. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  182. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  183. package/src/ssr/HelpPanelView.island.tsx +27 -0
  184. package/src/ssr/Layout.tsx +25 -2
  185. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  186. package/src/ssr/LayoutHelp.tsx +222 -419
  187. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
  188. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  189. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  190. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  191. package/src/ssr/MobileNavigation.tsx +2 -2
  192. package/src/ssr/PanelLayout.tsx +9 -1
  193. package/src/ssr/PanelRuntime.island.tsx +7 -0
  194. package/src/ssr/PanelSlot.tsx +53 -3
  195. package/src/ssr/ShellRuntime.island.tsx +11 -2
  196. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  197. package/src/ssr/app-color.ts +14 -9
  198. package/src/ssr/app-navigation.ts +16 -3
  199. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  200. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  201. package/src/ssr/help-messages.ts +4 -0
  202. package/src/ssr/help.ts +21 -0
  203. package/src/ssr/hints.ts +25 -0
  204. package/src/ssr/index.ts +3 -0
  205. package/src/ssr/layout-context.ts +2 -0
  206. package/src/ssr/layout-help-article.server.ts +50 -0
  207. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  208. package/src/ssr/layout-help-panel-document.ts +67 -0
  209. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  210. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  211. package/src/ssr/layout-help-panel-modules.ts +7 -0
  212. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  213. package/src/ssr/layout-help-panel-search.tsx +69 -0
  214. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  215. package/src/ssr/layout-help-panel-state.ts +141 -0
  216. package/src/ssr/layout-help-panel-types.ts +19 -0
  217. package/src/ssr/layout-help-start.ts +33 -7
  218. package/src/ssr/layout-help-views.tsx +484 -0
  219. package/src/ssr/panel-consent.ts +29 -0
  220. package/src/ssr/panel-context.ts +10 -0
  221. package/src/ssr/panel-idle.ts +9 -0
  222. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  223. package/src/ssr/platform-messages.ts +18 -0
  224. package/src/ssr/shell-data.tsx +2 -1
  225. package/src/styles/approvals.css +6 -0
  226. package/src/styles/effects.css +213 -9
  227. package/src/styles/file-chooser.css +2 -1
  228. package/src/styles/global.css +49 -12
  229. package/src/styles/panel.css +47 -4
  230. package/src/styles/resource-search.css +6 -2
  231. package/src/styles/tokens.css +17 -11
  232. package/src/styles/utilities-buttons.css +3 -2
  233. package/src/styles/utilities-data.css +2 -1
  234. package/src/styles/utilities-feedback.css +2 -2
  235. package/src/styles/utilities-layout.css +3 -13
  236. package/src/styles/utilities-markdown-editor.css +6 -6
  237. package/src/styles/utilities-navigation.css +17 -22
@@ -1,5 +1,14 @@
1
1
  import { dates, i18n } from "@k2b/stdlib";
2
- import type { CapabilityActionSentences, CapabilityPresentationCatalog } from "../contracts/capabilities";
2
+ import type {
3
+ CapabilityActionCallReview,
4
+ CapabilityActionOutcome,
5
+ CapabilityActionReceipt,
6
+ CapabilityActionSentences,
7
+ CapabilityAskReason,
8
+ CapabilityPerson,
9
+ CapabilityPresentationCatalog,
10
+ CapabilitySlot,
11
+ } from "../contracts/capabilities";
3
12
  import { canonicalLocale, localeFallbackChain, normalizeLocale } from "../shared/locale";
4
13
  import { normalizeTimeZone } from "../shared/time";
5
14
 
@@ -78,12 +87,38 @@ const words = i18n.define({
78
87
  baseLocale: "en",
79
88
  messages: {
80
89
  en: {
90
+ question: ({ sentence }: { sentence: string }) => `${sentence}?`,
91
+ rejected: ({ action }: { action: string }) => `Not done: ${action}`,
92
+ unknown: ({ action }: { action: string }) => `Not known yet whether this happened: ${action}`,
93
+ unknownStill: ({ action }: { action: string }) => `Still not known whether this happened: ${action}`,
94
+ people: ({ count }: { count: number }) => `${count} ${new Intl.PluralRules("en").select(count) === "one" ? "person" : "people"}`,
95
+ sends: "I'm asking because this leaves Cloud.",
96
+ deletes: "I'm asking because you can't bring it back afterwards.",
97
+ cannot_undo: "I'm asking because you can't set this back.",
98
+ involves_people: "I'm asking because this involves or notifies other people.",
99
+ changes_access: "I'm asking because this changes who can see it.",
100
+ keeps_working: "I'm asking because this keeps acting without you.",
101
+ sensitive: "I'm asking because this concerns payments, invoices, addresses, or sign-in data.",
102
+ app_asks: ({ app }: { app: string }) => `I'm asking because ${app} always asks for this.`,
81
103
  yes: "yes",
82
104
  no: "no",
83
105
  more: ({ count }: { count: number }) => `${count} more`,
84
106
  why: "Why",
85
107
  },
86
108
  de: {
109
+ question: ({ sentence }) => `${sentence}?`,
110
+ rejected: ({ action }) => `Nicht ausgeführt: ${action}`,
111
+ unknown: ({ action }) => `Ob ${action} passiert ist, weiß ich noch nicht.`,
112
+ unknownStill: ({ action }) => `Ob ${action} passiert ist, weiß ich weiterhin nicht.`,
113
+ people: ({ count }) => `${count} ${new Intl.PluralRules("de").select(count) === "one" ? "Person" : "Personen"}`,
114
+ sends: "Ich frage, weil das Cloud verlässt.",
115
+ deletes: "Ich frage, weil du das danach nicht zurückholen kannst.",
116
+ cannot_undo: "Ich frage, weil du das nicht zurückstellen kannst.",
117
+ involves_people: "Ich frage, weil das andere Personen einbindet oder benachrichtigt.",
118
+ changes_access: "Ich frage, weil sich ändert, wer das sehen kann.",
119
+ keeps_working: "Ich frage, weil das ohne dich weiterwirkt.",
120
+ sensitive: "Ich frage, weil das Zahlungen, Rechnungen, Adressen oder Zugangsdaten betrifft.",
121
+ app_asks: ({ app }) => `Ich frage, weil ${app} hier jedes Mal fragt.`,
87
122
  yes: "ja",
88
123
  no: "nein",
89
124
  more: ({ count }) => `${count} weitere`,
@@ -411,3 +446,184 @@ export const capabilitySentenceFields = (input: {
411
446
  }
412
447
  return [...fields.values()];
413
448
  };
449
+
450
+ export type CapabilitySlotSentenceContext = { locale: string; timeZone: string };
451
+ export type CapabilitySentencePart =
452
+ | { kind: "text"; text: string }
453
+ | { kind: "chip"; name: string; slot: CapabilitySlot; label: string; full: string };
454
+ export const capabilityPersonText = (person: CapabilityPerson): string =>
455
+ person.name && person.address ? `${person.name} <${person.address}>` : (person.name ?? person.address ?? "");
456
+
457
+ const slotText = (slot: CapabilitySlot, context: CapabilitySlotSentenceContext, mode: "chip" | "plain" | "button" | "full"): string => {
458
+ const t = words.resolve([context.locale]).t;
459
+ switch (slot.kind) {
460
+ case "text":
461
+ return slot.value;
462
+ case "count": {
463
+ const plural = new Intl.PluralRules(context.locale).select(slot.value);
464
+ return `${new Intl.NumberFormat(context.locale).format(slot.value)} ${slot.noun[plural] ?? slot.noun.other}`;
465
+ }
466
+ case "person": {
467
+ const total = slot.total ?? slot.people.length;
468
+ if (mode === "button")
469
+ return total === 1 ? plain(slot.people[0]!.name ?? slot.people[0]!.address ?? "", 24, "middle") : t.people({ count: total });
470
+ const shown = mode === "full" ? slot.people : slot.people.slice(0, 3);
471
+ const list = shown.map((person) =>
472
+ mode === "chip" ? plain(person.name ?? person.address ?? "", 40, "middle") : capabilityPersonText(person),
473
+ );
474
+ const rest = total - shown.length;
475
+ if (rest > 0) list.push(t.more({ count: rest }));
476
+ return new Intl.ListFormat(context.locale, { type: "conjunction" }).format(list);
477
+ }
478
+ case "object":
479
+ case "place": {
480
+ if (mode === "button") return plain(slot.title, 24, "middle");
481
+ if (mode === "chip" || mode === "full") return slot.title;
482
+ return context.locale.toLowerCase().startsWith("de") ? `„${slot.title}“` : `“${slot.title}”`;
483
+ }
484
+ case "time": {
485
+ const date = (value: string) => {
486
+ const options = { locale: context.locale, timeZone: slot.format === "date" ? "UTC" : normalizeTimeZone(context.timeZone) };
487
+ return slot.format === "date" ? dates.formatDate(`${value}T12:00:00.000Z`, options) : dates.formatDateTime(value, options);
488
+ };
489
+ return slot.end ? `${date(slot.start)} – ${date(slot.end)}` : date(slot.start);
490
+ }
491
+ }
492
+ };
493
+
494
+ /** Typed slots are data, never expressions. All formatting is explicit and independent of the process zone. */
495
+ export const capabilitySentenceParts = (
496
+ text: string,
497
+ slots: Readonly<Record<string, CapabilitySlot>>,
498
+ context: CapabilitySlotSentenceContext,
499
+ ): CapabilitySentencePart[] => {
500
+ const parts: CapabilitySentencePart[] = [];
501
+ let index = 0;
502
+ for (const match of text.matchAll(/\{([a-z][A-Za-z0-9]{0,31})\}/g)) {
503
+ if (match.index > index) parts.push({ kind: "text", text: text.slice(index, match.index) });
504
+ const name = match[1]!;
505
+ const slot = Object.hasOwn(slots, name) ? slots[name] : undefined;
506
+ if (!slot) throw new Error(`Unknown sentence slot ${name}`);
507
+ const label = slotText(slot, context, "chip");
508
+ const full = slotText(slot, context, "full");
509
+ parts.push(
510
+ slot.kind === "text" || slot.kind === "count"
511
+ ? { kind: "text", text: label }
512
+ : { kind: "chip", name, slot, label: slot.kind === "person" ? label : plain(label, 40, "middle"), full },
513
+ );
514
+ index = match.index + match[0].length;
515
+ }
516
+ if (index < text.length) parts.push({ kind: "text", text: text.slice(index) });
517
+ return parts;
518
+ };
519
+ const sentenceText = (
520
+ text: string,
521
+ slots: Readonly<Record<string, CapabilitySlot>>,
522
+ context: CapabilitySlotSentenceContext,
523
+ mode: "plain" | "button",
524
+ max = 400,
525
+ ): string => {
526
+ const render = (slotMax: number) =>
527
+ text.replace(/\{([a-z][A-Za-z0-9]{0,31})\}/g, (_, name: string) => {
528
+ const slot = Object.hasOwn(slots, name) ? slots[name] : undefined;
529
+ if (!slot) throw new Error(`Unknown sentence slot ${name}`);
530
+ return plain(slotText(slot, context, mode), slotMax, "middle");
531
+ });
532
+ let result = render(Number.MAX_SAFE_INTEGER);
533
+ if (Array.from(result).length > max) result = render(40);
534
+ return plain(result, max);
535
+ };
536
+ export const capabilitySentenceText = (
537
+ text: string,
538
+ slots: Readonly<Record<string, CapabilitySlot>>,
539
+ context: CapabilitySlotSentenceContext,
540
+ ): string => sentenceText(text, slots, context, "plain");
541
+ export type CapabilityReviewSentenceForm =
542
+ | "question"
543
+ | "unknownStill"
544
+ | "running"
545
+ | "done"
546
+ | "rejected"
547
+ | "notRun"
548
+ | "unknown"
549
+ | "short"
550
+ | "confirm"
551
+ | "decline";
552
+ export const capabilityReviewSentence = (
553
+ review: CapabilityActionCallReview,
554
+ form: CapabilityReviewSentenceForm,
555
+ context: { timeZone: string },
556
+ ): string => {
557
+ const rendering = { locale: review.locale, timeZone: context.timeZone };
558
+ const t = words.resolve([review.locale]).t;
559
+ const action = () => capabilitySentenceText(review.sentence.action, review.slots, rendering);
560
+ if (form === "question") return plain(t.question({ sentence: action() }), 400);
561
+ if (form === "unknown") return plain(t.unknown({ action: action() }), 400);
562
+ if (form === "unknownStill") return plain(t.unknownStill({ action: action() }), 400);
563
+ if (form === "confirm" || form === "decline")
564
+ return sentenceText(review.sentence[form], review.slots, rendering, "button", Number.MAX_SAFE_INTEGER);
565
+ const template = form === "notRun" ? (review.sentence.notRun ?? review.sentence.rejected) : review.sentence[form];
566
+ if (template) return capabilitySentenceText(template, review.slots, rendering);
567
+ if (form === "short") return action();
568
+ return plain(t.rejected({ action: action() }), 400);
569
+ };
570
+ export const capabilityOutcomeSentence = (
571
+ review: CapabilityActionCallReview,
572
+ outcome: CapabilityActionOutcome,
573
+ receipt: CapabilityActionReceipt | undefined,
574
+ context: { timeZone: string },
575
+ ): string =>
576
+ outcome === "done" && receipt
577
+ ? capabilitySentenceText(receipt.text, receipt.slots ?? {}, { locale: review.locale, timeZone: context.timeZone })
578
+ : capabilityReviewSentence(review, outcome === "not_run" ? "notRun" : outcome, context);
579
+ export const capabilityAskReasonText = (reason: CapabilityAskReason, context: { locale: string; appName: string }): string => {
580
+ const t = words.resolve([context.locale]).t;
581
+ return reason === "app_asks" ? t.app_asks({ app: context.appName }) : t[reason];
582
+ };
583
+
584
+ /** Splits a Cloud frame such as "Ob {action} passiert ist, weiß ich noch nicht." around the inserted sentence. */
585
+ const framed = (frame: (input: { action: string }) => string, inner: CapabilitySentencePart[]): CapabilitySentencePart[] => {
586
+ const marker = "\u0000";
587
+ const [before = "", after = ""] = frame({ action: marker }).split(marker);
588
+ return [
589
+ ...(before ? [{ kind: "text" as const, text: before }] : []),
590
+ ...inner,
591
+ ...(after ? [{ kind: "text" as const, text: after }] : []),
592
+ ];
593
+ };
594
+
595
+ /**
596
+ * One form of a review as parts for chips: the same words as `capabilityReviewSentence`, with Cloud's frames (the
597
+ * question mark, "not known yet", the default rejection) around the app's sentence. Buttons and `short` stay text.
598
+ */
599
+ export const capabilityReviewSentenceParts = (
600
+ review: CapabilityActionCallReview,
601
+ form: Exclude<CapabilityReviewSentenceForm, "short" | "confirm" | "decline">,
602
+ context: { timeZone: string },
603
+ ): CapabilitySentencePart[] => {
604
+ const rendering = { locale: review.locale, timeZone: context.timeZone };
605
+ const t = words.resolve([review.locale]).t;
606
+ const action = () => capabilitySentenceParts(review.sentence.action, review.slots, rendering);
607
+ if (form === "question") return framed(({ action: sentence }) => t.question({ sentence }), action());
608
+ if (form === "unknown") return framed(t.unknown, action());
609
+ if (form === "unknownStill") return framed(t.unknownStill, action());
610
+ const template = form === "notRun" ? (review.sentence.notRun ?? review.sentence.rejected) : review.sentence[form];
611
+ if (template) return capabilitySentenceParts(template, review.slots, rendering);
612
+ return framed(t.rejected, action());
613
+ };
614
+
615
+ /** The parts of a receipt the app returned, in the review's language. */
616
+ export const capabilityReceiptParts = (
617
+ receipt: CapabilityActionReceipt,
618
+ context: { locale: string; timeZone: string },
619
+ ): CapabilitySentencePart[] => capabilitySentenceParts(receipt.text, receipt.slots ?? {}, context);
620
+
621
+ /** Cloud's frame for an outcome it could not see, around any plain sentence. */
622
+ export const capabilityUnknownText = (action: string, locale: string, still = false): string => {
623
+ const t = words.resolve([locale]).t;
624
+ return plain(still ? t.unknownStill({ action }) : t.unknown({ action }), 400);
625
+ };
626
+
627
+ /** Cloud's question frame around a plain sentence, such as Cloud's own sentence for a call without a review. */
628
+ export const capabilityQuestionText = (sentence: string, locale: string): string =>
629
+ plain(words.resolve([locale]).t.question({ sentence }), 400);
@@ -8,6 +8,7 @@ import {
8
8
  capabilityResultSchema,
9
9
  } from "../contracts/capabilities";
10
10
  import type { CompiledCapabilities } from "./capabilities";
11
+ import { capabilityActionReceiptIssues } from "./capability-reviews";
11
12
 
12
13
  const failure = (status: number, code: string, message: string) => Response.json({ code, message }, { status });
13
14
  export async function invokeCapabilityStream(params: {
@@ -26,6 +27,12 @@ export async function invokeCapabilityStream(params: {
26
27
  const receipt = (value: unknown) => {
27
28
  const parsed = capabilityResultSchema(operation.definition.data).parse(value);
28
29
  if (parsed.stream) throw new Error("Stream receipts cannot issue another stream");
30
+ // The same receipt rules as an ordinary result, so a stream completion cannot skip them.
31
+ if (parsed.receipt !== undefined) {
32
+ const issues =
33
+ params.kind === "queries" ? ["Queries cannot return a receipt"] : capabilityActionReceiptIssues(parsed.receipt, parsed.links);
34
+ if (issues.length > 0) throw new Error(`Stream result receipt is invalid: ${issues.join("; ")}`);
35
+ }
29
36
  return parsed;
30
37
  };
31
38
  try {
@@ -2,6 +2,7 @@ import { type AppSkillDefinition, appSkillManifestHash, registerAppSkills, valid
2
2
  import { startLiveOutbox } from "../events/live";
3
3
  import { registerHelp } from "../services/help";
4
4
  import { preloadLayoutHelp } from "../ssr/help";
5
+ import { preloadPanelConsent } from "../ssr/panel-consent";
5
6
  import { invokeCapabilityStream } from "./capability-streams";
6
7
  import { bindProcessApplicationId, clearProcessApplicationId } from "./process-identity";
7
8
  /**
@@ -75,29 +76,31 @@ import { capabilityMessages } from "../shared/capability-messages";
75
76
  import { normalizeLocale } from "../shared/locale";
76
77
  import { escapeHtml } from "../shared/markdown/shared";
77
78
  import { type CloudThemePreference, readThemePreferenceFromCookieHeader, themeBootstrapScript } from "../shared/theme";
79
+ import { appAccentStyle } from "../ssr/app-appearance";
78
80
  import { appFaviconHref } from "./app-favicon";
79
81
  import { compileAppPresentation } from "./app-presentation";
80
82
  import { readBoundedJson } from "./bounded-json";
81
- import { appRuntimeMetadata } from "./build-metadata";
83
+ import { appRuntimeMetadata, buildMetadata } from "./build-metadata";
82
84
  import { compileCapabilities, invokeCompiledCapability, reviewCompiledCapability, serializeCapabilityProviderResult } from "./capabilities";
83
85
  import { cliPluginRoutePrefixes, createCliPluginRoutes, validateAppCliModules } from "./cli-plugins";
84
86
  import { FONT_PRELOAD_LINKS } from "./font-preloads";
85
- import { createHeartbeat } from "./heartbeat";
87
+ import { createHeartbeat, createRegistryMembership } from "./heartbeat";
86
88
  import { compileHelp } from "./help";
87
89
  import { createPageResponses } from "./page-responses";
88
90
  import { configurePostgresApplicationName } from "./postgres-application-name";
89
91
  import { getProcessSync, startProcessSync } from "./process-sync";
90
92
  import { APP_READINESS_PATH, appReadinessResponse } from "./readiness";
91
- import { appRegistry, type CapabilityRegistryRecord, capabilityRegistry } from "./registry";
93
+ import { appInstanceRegistry, appMembershipLock, appRegistry, type CapabilityRegistryRecord, capabilityRegistry } from "./registry";
92
94
  import { canonicalRoute, isOwnAppPath } from "./registry-validation";
93
- import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
94
- import { servePublicAsset } from "./static-assets";
95
+ import { ensureRuntimeWatcher, getCurrentRuntime, registeredCoreRelease, stopRuntimeWatcher } from "./runtime-watcher";
96
+ import { runSetupExclusively } from "./setup-lock";
97
+ import { publicFileVersion, servePublicAsset, sharedStylesheetVersion, versionedPublicHref } from "./static-assets";
95
98
  import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
96
99
  import { WEB_VITALS_ASSET_HREF } from "./web-vitals-asset";
97
100
  import { compileWidgetDeclarations } from "./widget-declarations";
98
101
 
99
- /** Cache-busting version stamp — changes on every server start / rebuild. */
100
- const v = Date.now();
102
+ /** Only development links use this start stamp to pick up rebuilds. */
103
+ const developmentStamp = Date.now();
101
104
 
102
105
  export type PageOptions = {
103
106
  title?: string;
@@ -110,7 +113,7 @@ export type PageOptions = {
110
113
  pwa?: { name: string };
111
114
  };
112
115
 
113
- type ResolvedPageOptions = PageOptions & { performanceRoute?: string; themePreference?: CloudThemePreference };
116
+ type ResolvedPageOptions = PageOptions & { performanceRoute?: string; themePreference?: CloudThemePreference; appAccent?: string };
114
117
 
115
118
  // ── Public types ────────────────────────────────────────────────────────────
116
119
 
@@ -377,13 +380,20 @@ const validateAppNavBadge = (opts: Pick<AppOptions, "id" | "routes" | "nav">): v
377
380
  }
378
381
  };
379
382
 
380
- const validateAppPanel = (opts: Pick<AppOptions, "id" | "routes" | "panel">): AppMeta["panel"] => {
383
+ const validateAppPanel = (opts: Pick<AppOptions, "id" | "routes" | "basePath" | "panel">): AppMeta["panel"] => {
381
384
  const panel = opts.panel;
382
385
  if (panel === undefined) return undefined;
383
386
  if (!panel || typeof panel !== "object" || Array.isArray(panel)) throw new Error(`App "${opts.id}" declares panel; it must be an object`);
384
387
  if (typeof panel.href !== "string" || !isOwnAppPath(panel.href, opts.routes)) {
385
388
  throw new Error(`App "${opts.id}" declares panel.href "${panel.href}"; it must be a same-origin path below one of its routes`);
386
389
  }
390
+ const pageBase = `${opts.basePath?.replace(/\/$/, "") ?? ""}/help`;
391
+ const path = new URL(panel.href, "https://cloud.invalid").pathname;
392
+ if (path === pageBase || path.startsWith(`${pageBase}/`)) {
393
+ throw new Error(
394
+ `App "${opts.id}" declares panel.href "${panel.href}"; it must not be at or below its Help path "${pageBase}", which the framework uses for Help`,
395
+ );
396
+ }
387
397
  for (const field of ["label", "icon"] as const) {
388
398
  if (typeof panel[field] !== "string" || !panel[field].trim())
389
399
  throw new Error(`App "${opts.id}" declares panel.${field}; it must be a non-empty string`);
@@ -434,7 +444,18 @@ export const defineApp = <
434
444
  // Every island instance already mounts inside its own error boundary; this
435
445
  // only gives the fallback Cloud's look and the reader's language.
436
446
  errorFallback: fileURLToPath(new URL("../ssr/IslandError.tsx", import.meta.url)),
437
- template: ({ body, scripts, title, description, theme, themePreference, lang, performanceRoute, pwa }) => {
447
+ template: ({ body, scripts, title, description, theme, themePreference, lang, performanceRoute, pwa, appAccent }) => {
448
+ // A URL changes only when what it names changes: the app's own files by their content, Core's shared sheets by
449
+ // the release of the Core build in the registry, so every app links them under one URL per Core build.
450
+ const sharedVersion = isDevelopment ? String(developmentStamp) : sharedStylesheetVersion(registeredCoreRelease());
451
+ const appStylesheet = `/public/${opts.id}/app.css`;
452
+ const appVersion = isDevelopment ? String(developmentStamp) : publicFileVersion(appStylesheet);
453
+ const faviconVersion = isDevelopment
454
+ ? developmentStamp
455
+ : opts.id === "core"
456
+ ? undefined
457
+ : publicFileVersion(`/public/${opts.id}/favicon.svg`);
458
+ const accentStyle = appAccentStyle(appAccent);
438
459
  const fixed = theme !== undefined;
439
460
  const resolved = theme ?? (themePreference === "light" || themePreference === "dark" ? themePreference : undefined);
440
461
  // App pages paint the canvas before any stylesheet and give it to the
@@ -478,17 +499,17 @@ export const defineApp = <
478
499
  <title>${escapeHtml(title ?? "Cloud")}</title>
479
500
  <meta name="description" content="${escapeHtml(description ?? "Cloud workspace")}">
480
501
  ${head.app}
481
- <link rel="icon" href="${appFaviconHref(opts.id, v)}">
502
+ <link rel="icon" href="${appFaviconHref(opts.id, faviconVersion)}">
482
503
  <style data-cloud-css-layers>@layer properties, theme, base, components, utilities;</style>
483
504
  <link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
484
505
  ${FONT_PRELOAD_LINKS}
485
- <link rel="stylesheet" href="/public/fonts.css?v=${v}">
486
- <link rel="stylesheet" href="/public/tabler-icons.css?v=${v}">
487
- <link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
488
- <link rel="stylesheet" href="/public/global.css?v=${v}">${head.styles}
506
+ <link rel="stylesheet" href="${versionedPublicHref("/public/fonts.css", sharedVersion)}">
507
+ <link rel="stylesheet" href="${versionedPublicHref("/public/tabler-icons.css", sharedVersion)}">
508
+ <link rel="stylesheet" href="${versionedPublicHref(appStylesheet, appVersion)}">
509
+ <link rel="stylesheet" href="${versionedPublicHref("/public/global.css", sharedVersion)}">${head.styles}
489
510
  <script>${themeBootstrapScript}</script>
490
511
  </head>
491
- <body class="k2b-ui" data-k2b-app-workspace-controller="global">
512
+ <body class="k2b-ui${accentStyle ? " app-accent-scope" : ""}"${accentStyle ? ` style="${escapeHtml(accentStyle)}"` : ""} data-k2b-app-workspace-controller="global">
492
513
  ${body}
493
514
  </body>
494
515
  ${scripts}
@@ -520,6 +541,7 @@ export const defineApp = <
520
541
  await Promise.all([
521
542
  preloadLayoutAnnouncements(c),
522
543
  preloadLayoutHelp(c),
544
+ user ? preloadPanelConsent(c) : undefined,
523
545
  user ? readRailSnapshot(user).then((snapshot) => c.set("railPreferences", snapshot)) : undefined,
524
546
  ]);
525
547
  }),
@@ -665,9 +687,16 @@ export const defineApp = <
665
687
  cliModules: cliModules.length > 0 ? cliModules : undefined,
666
688
  };
667
689
 
690
+ // Both shared entries follow one process membership; creating it touches no Sync resources.
691
+ const membership = createRegistryMembership(meta.id, {
692
+ registry: appInstanceRegistry,
693
+ lock: appMembershipLock,
694
+ startedAt: entry.startedAt,
695
+ });
668
696
  // Heartbeat
669
697
  const heartbeat = createHeartbeat(meta.id, entry, {
670
698
  registry: appRegistry(),
699
+ membership,
671
700
  beforeWrite: async () => {
672
701
  if (compiledHelp) await registerHelp(compiledHelp.corpus);
673
702
  if (appSkills && skillsManifestHash) await registerAppSkills(meta.id, appSkills, skillsManifestHash);
@@ -696,6 +725,7 @@ export const defineApp = <
696
725
  ? createHeartbeat(meta.id, capabilityEntry, {
697
726
  key: `capabilities/${meta.id}`,
698
727
  registry: capabilityRegistry(),
728
+ membership,
699
729
  onError: (error) =>
700
730
  log.error("Capability registry heartbeat failed", {
701
731
  appId: meta.id,
@@ -741,7 +771,7 @@ export const defineApp = <
741
771
  .get(APP_READINESS_PATH, () => appReadinessResponse(meta.id))
742
772
  .route(ssrMountPath, routes(config))
743
773
  .all(`${ssrMountPath}/*`, (c) => c.notFound())
744
- .all("/public/*", servePublicAsset(isDevelopment))
774
+ .all("/public/*", servePublicAsset(isDevelopment, sharedStylesheetVersion(buildMetadata.release)))
745
775
  .use(
746
776
  "/_internal/sync/*",
747
777
  requireInvocation((c) => ({
@@ -976,7 +1006,8 @@ export const defineApp = <
976
1006
 
977
1007
  if (!startOpts.skipSetup && startOpts.lifecycle?.setup) {
978
1008
  log.info(`Setup: ${meta.id}`);
979
- await startOpts.lifecycle.setup(cloudCtx);
1009
+ const setup = startOpts.lifecycle.setup;
1010
+ await runSetupExclusively(meta.id, () => setup(cloudCtx));
980
1011
  }
981
1012
  if (meta.id === "core") await startOutbox();
982
1013
 
@@ -3,7 +3,8 @@ import plexPreset from "@k2b/ui/fonts/plex.css" with { type: "text" };
3
3
 
4
4
  /**
5
5
  * Preload tags for the IBM Plex Sans faces that every page sets in its first
6
- * frame: Latin text at 400, 500 (buttons, labels) and 600 (headings).
6
+ * frame: Latin text at 400, 500 (list titles, medium text) and 600 (headings,
7
+ * buttons, labels).
7
8
  *
8
9
  * The preset declares its faces with `font-display: swap`. Without a preload
9
10
  * the browser requests a face only when layout needs it, paints the fallback