@capybara-hq/kernel 0.1.0-rc.10 → 0.1.0-rc.12

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 (106) hide show
  1. package/dist/admin/admin.schemas.d.ts +3 -0
  2. package/dist/agents/agents.schemas.d.ts +3 -0
  3. package/dist/agents/agents.service.js +11 -1
  4. package/dist/agents/definition.d.ts +1 -0
  5. package/dist/agents/definition.js +7 -3
  6. package/dist/agents/org-templates.js +4 -0
  7. package/dist/agents/pending-connectors.service.js +1 -1
  8. package/dist/catalog/catalog-content.d.ts +33 -1
  9. package/dist/catalog/catalog-content.js +65 -2
  10. package/dist/catalog/catalog-items.js +1 -0
  11. package/dist/catalog/catalog-scope.service.d.ts +29 -3
  12. package/dist/catalog/catalog-scope.service.js +82 -10
  13. package/dist/catalog/catalog.service.d.ts +7 -5
  14. package/dist/catalog/catalog.service.js +26 -5
  15. package/dist/catalog/claude/translate.js +3 -0
  16. package/dist/catalog/component-scope.controller.d.ts +18 -6
  17. package/dist/catalog/component-scope.controller.js +36 -13
  18. package/dist/catalog/component-scope.schemas.d.ts +4 -0
  19. package/dist/catalog/component-scope.service.d.ts +59 -9
  20. package/dist/catalog/component-scope.service.js +156 -27
  21. package/dist/catalog/component-state.d.ts +20 -3
  22. package/dist/catalog/component-state.js +40 -14
  23. package/dist/catalog/marketplaces.service.d.ts +1 -1
  24. package/dist/catalog/org-catalog.service.js +3 -0
  25. package/dist/catalog/owned-items.js +7 -0
  26. package/dist/catalog/platform-components.d.ts +23 -3
  27. package/dist/catalog/platform-components.js +118 -9
  28. package/dist/catalog/platform-migration.js +2 -1
  29. package/dist/catalog/plugin-import.d.ts +24 -4
  30. package/dist/catalog/plugin-import.js +41 -7
  31. package/dist/catalog/plugin-install.d.ts +20 -1
  32. package/dist/catalog/plugin-install.js +71 -1
  33. package/dist/catalog/plugin-package.d.ts +41 -4
  34. package/dist/catalog/plugin-package.js +127 -7
  35. package/dist/catalog/plugin-upgrade.d.ts +13 -2
  36. package/dist/catalog/plugin-upgrade.js +124 -6
  37. package/dist/catalog/unshare.js +1 -0
  38. package/dist/conversations/conversations.service.d.ts +9 -0
  39. package/dist/conversations/conversations.service.js +9 -2
  40. package/dist/conversations/events.d.ts +1 -0
  41. package/dist/db/kernel-schema.d.ts +54 -4
  42. package/dist/errors.d.ts +27 -0
  43. package/dist/errors.js +27 -0
  44. package/dist/http/api.module.js +7 -0
  45. package/dist/identity/accounts.d.ts +11 -4
  46. package/dist/identity/accounts.js +23 -10
  47. package/dist/identity/me.controller.d.ts +10 -10
  48. package/dist/identity/me.controller.js +8 -6
  49. package/dist/identity/me.schemas.d.ts +13 -3
  50. package/dist/identity/me.schemas.js +10 -2
  51. package/dist/identity/settings.d.ts +27 -0
  52. package/dist/identity/settings.js +64 -0
  53. package/dist/orgs/settings.service.d.ts +31 -0
  54. package/dist/orgs/settings.service.js +31 -1
  55. package/dist/platform/exclusions.js +26 -0
  56. package/dist/platform/operation.js +6 -2
  57. package/dist/platform/ops/admin.ops.d.ts +3 -1
  58. package/dist/platform/ops/admin.ops.js +25 -7
  59. package/dist/platform/ops/agents.ops.js +19 -6
  60. package/dist/platform/ops/components.ops.js +1 -0
  61. package/dist/platform/ops/org-settings.ops.d.ts +3 -1
  62. package/dist/platform/ops/org-settings.ops.js +33 -8
  63. package/dist/platform/ops/orgs.ops.js +1 -0
  64. package/dist/platform/ops/platform.ops.d.ts +3 -1
  65. package/dist/platform/ops/platform.ops.js +43 -16
  66. package/dist/platform/ops/themes.ops.d.ts +14 -0
  67. package/dist/platform/ops/themes.ops.js +47 -0
  68. package/dist/platform/platform-ops.module.d.ts +2 -1
  69. package/dist/platform/platform-ops.module.js +5 -0
  70. package/dist/platform/route-schemas.js +3 -0
  71. package/dist/runtime/subagents/definition.d.ts +3 -2
  72. package/dist/runtime/subagents/definition.js +2 -1
  73. package/dist/themes/render-css.d.ts +12 -0
  74. package/dist/themes/render-css.js +132 -0
  75. package/dist/themes/theme-delivery.service.d.ts +34 -0
  76. package/dist/themes/theme-delivery.service.js +111 -0
  77. package/dist/themes/theme-state.d.ts +95 -0
  78. package/dist/themes/theme-state.js +203 -0
  79. package/dist/themes/theme-usage.d.ts +61 -0
  80. package/dist/themes/theme-usage.js +98 -0
  81. package/dist/themes/themes-public.controller.d.ts +28 -0
  82. package/dist/themes/themes-public.controller.js +92 -0
  83. package/dist/themes/themes.controller.d.ts +13 -0
  84. package/dist/themes/themes.controller.js +38 -0
  85. package/dist/themes/themes.module.d.ts +3 -0
  86. package/dist/themes/themes.module.js +21 -0
  87. package/dist/themes/themes.schemas.d.ts +3 -0
  88. package/dist/themes/themes.schemas.js +2 -0
  89. package/dist/themes/themes.service.d.ts +50 -0
  90. package/dist/themes/themes.service.js +99 -0
  91. package/dist/tools/connector-account-policies.d.ts +51 -0
  92. package/dist/tools/connector-account-policies.js +165 -0
  93. package/dist/tools/connectors.controller.d.ts +35 -4
  94. package/dist/tools/connectors.controller.js +66 -7
  95. package/dist/tools/connectors.schemas.d.ts +11 -0
  96. package/dist/tools/connectors.schemas.js +11 -0
  97. package/dist/tools/connectors.service.d.ts +2 -0
  98. package/dist/tools/connectors.service.js +1 -0
  99. package/dist/tools/pipeline.d.ts +1 -1
  100. package/dist/tools/policy.d.ts +23 -1
  101. package/dist/tools/policy.js +99 -24
  102. package/dist/tools/tools.module.js +3 -0
  103. package/package.json +3 -3
  104. package/src/migrations/0102_themes.sql +64 -0
  105. package/src/migrations/0103_ui_theme_setting.sql +7 -0
  106. package/src/migrations/0104_connector_tool_account_policies.sql +16 -0
@@ -50,6 +50,9 @@ export declare const DeploymentSettingsBody: z.ZodObject<{
50
50
  deny: "deny";
51
51
  }>>;
52
52
  orgMarketplaceBrowse: z.ZodOptional<z.ZodBoolean>;
53
+ uiTheme: z.ZodOptional<z.ZodObject<{
54
+ id: z.ZodNullable<z.ZodString>;
55
+ }, z.core.$strict>>;
53
56
  }, z.core.$strict>;
54
57
  export declare const PurposeParams: z.ZodObject<{
55
58
  purpose: z.ZodString;
@@ -33,6 +33,7 @@ export declare const AgentCreateDefinition: z.ZodObject<{
33
33
  tools: z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>;
34
34
  }, z.core.$strict>], "kind">>>;
35
35
  toolPolicies: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEnum<{
36
+ allow: "allow";
36
37
  ask: "ask";
37
38
  deny: "deny";
38
39
  }>>>;
@@ -116,6 +117,7 @@ export declare const AgentCreateBody: z.ZodObject<{
116
117
  tools: z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>;
117
118
  }, z.core.$strict>], "kind">>>;
118
119
  toolPolicies: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEnum<{
120
+ allow: "allow";
119
121
  ask: "ask";
120
122
  deny: "deny";
121
123
  }>>>;
@@ -231,6 +233,7 @@ export declare const AgentUpdateBody: z.ZodObject<{
231
233
  tools: z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>;
232
234
  }, z.core.$strict>], "kind">>>;
233
235
  toolPolicies: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEnum<{
236
+ allow: "allow";
234
237
  ask: "ask";
235
238
  deny: "deny";
236
239
  }>>>;
@@ -8,6 +8,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
10
  import { randomUUID } from "node:crypto";
11
+ import { parseToolKey } from "@capybara-hq/plugin-sdk";
11
12
  import { Inject, Injectable } from "@nestjs/common";
12
13
  import { sql } from "kysely";
13
14
  import { ApiError } from "../errors.js";
@@ -310,7 +311,16 @@ let AgentsService = class AgentsService {
310
311
  message: "subagentTools is set only on a sub-agent's definition at dispatch",
311
312
  });
312
313
  }
313
- for (const key of Object.keys(def.toolPolicies)) {
314
+ for (const [key, policy] of Object.entries(def.toolPolicies)) {
315
+ // `allow` counts only for a connector tool (decision 2026-11-09): elsewhere a policy only
316
+ // tightens, so an `allow` there is refused rather than kept as a no-op
317
+ if (policy === "allow" && parseToolKey(key)?.kind !== "connector") {
318
+ details.push({
319
+ path: `toolPolicies.${key}`,
320
+ message: `"allow" applies only to a connector's tools; "${key}" can only be tightened (ask or deny)`,
321
+ });
322
+ continue;
323
+ }
314
324
  if (!isToolPolicyKey(key, keys)) {
315
325
  // an rc key (tool naming spec §8.3): saving writes current keys only, so name them
316
326
  const renamed = legacyKeySaveText(key, keys);
@@ -196,6 +196,7 @@ export declare const AgentDefinitionSchema: z.ZodObject<{
196
196
  deny: z.ZodOptional<z.ZodArray<z.ZodString>>;
197
197
  }, z.core.$strict>>;
198
198
  toolPolicies: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEnum<{
199
+ allow: "allow";
199
200
  ask: "ask";
200
201
  deny: "deny";
201
202
  }>>>;
@@ -207,10 +207,14 @@ export const AgentDefinitionSchema = z.object({
207
207
  */
208
208
  subagentTools: SubagentToolsSchema.optional(),
209
209
  /**
210
- * Tightened policies by tool key (`ask` / `deny` only, never `allow`); keys are tools the agent
211
- * could have (on or turned off). Older definitions without it read as empty.
210
+ * Policies by tool key; keys are tools the agent could have (on or turned off). `ask` / `deny`
211
+ * tighten any tool. `allow` is accepted only on a connector tool key and counts only for a
212
+ * personal-credential connector's tool (decision 2026-11-09): it overrides the org default and
213
+ * the member's own 询问, never the org's or the member's 禁止 (`tools/policy.ts`
214
+ * `connectorPolicy`); on any other tool it changes nothing. Older definitions without it read as
215
+ * empty.
212
216
  */
213
- toolPolicies: z.record(z.string(), z.enum(["ask", "deny"])).default({}),
217
+ toolPolicies: z.record(z.string(), z.enum(["allow", "ask", "deny"])).default({}),
214
218
  /**
215
219
  * 技能(M5 T13):`orgSkills` 是引用的组织技能 id(保存时校验属于本组织且未删除,不可重复)。
216
220
  * agent 自己的技能(`scope=agent`)自动对它生效,不进定义。可省略(较早保存的定义也没有),按空处理;
@@ -200,6 +200,10 @@ extensionTools = []) {
200
200
  const toolPolicies = {};
201
201
  const connectorPolicies = new Map();
202
202
  for (const [k, v] of Object.entries(def.toolPolicies ?? {})) {
203
+ // a template only tightens: an agent's `allow` (a connector tool's, decision 2026-11-09)
204
+ // is not carried into it
205
+ if (v === "allow")
206
+ continue;
203
207
  const parsed = parseToolKey(k);
204
208
  if (parsed !== null && parsed.kind !== "connector" && TOOL_KEY_RE.test(k)) {
205
209
  toolPolicies[k] = v;
@@ -134,7 +134,7 @@ let PendingConnectorsService = class PendingConnectorsService {
134
134
  }
135
135
  const k = connectorToolKey(c.slug, n);
136
136
  const prev = toolPolicies[k];
137
- if (prev === undefined || RANK[p] > RANK[prev])
137
+ if (prev === undefined || prev === "allow" || RANK[p] > RANK[prev])
138
138
  toolPolicies[k] = p;
139
139
  }
140
140
  // tools are on by default (tools default-on spec §3): binding an entry that names some tools
@@ -1,4 +1,4 @@
1
- import { type AgentTemplate, type Connector, type ConnectorTemplateFile, type ConnectorTemplateSpec, type LocalizedName, type PluginManifest } from "@capybara-hq/plugin-sdk";
1
+ import { type AgentTemplate, type Connector, type ConnectorTemplateFile, type ConnectorTemplateSpec, type LocalizedName, type ParsedTheme, type PluginManifest } from "@capybara-hq/plugin-sdk";
2
2
  import type { KernelSchemaDb } from "../db/kernel-schema.js";
3
3
  import type { ExtensionRegistry } from "../extensions/registry.js";
4
4
  import type { ExtensionUnavailableReason } from "../extensions/unavailable.js";
@@ -230,3 +230,35 @@ export declare function readCatalogAgentTemplates(storage: StorageService, v: Ca
230
230
  path: string;
231
231
  template: AgentTemplate;
232
232
  }[]>;
233
+ /** A theme of a catalog version ({@link readCatalogThemes}). */
234
+ export interface CatalogTheme {
235
+ /** The theme id (the file's base name). */
236
+ id: string;
237
+ /** Relative to the plugin root. */
238
+ path: string;
239
+ theme: ParsedTheme;
240
+ /** The theme's fonts and logo files (paths relative to the plugin root), sanitized. */
241
+ assets: SkillPackageFile[];
242
+ /** {@link themeAssetsDigest} of the version's records of those files. */
243
+ assetsDigest: string;
244
+ }
245
+ /** The asset paths a parsed theme references (fonts, logo mark and favicon), without `./`. */
246
+ export declare function themeAssetPaths(theme: ParsedTheme): string[];
247
+ /**
248
+ * The digest of a theme's assets as recorded on a catalog version (`files`): equal digests = the
249
+ * same fonts and logo. With the canonical definition it decides whether a theme changed.
250
+ */
251
+ export declare function themeAssetsDigest(files: {
252
+ path: string;
253
+ sha256: string;
254
+ }[], theme: ParsedTheme): string;
255
+ /**
256
+ * The themes of a catalog version (`manifest.themes`, listed explicitly since import), parsed from
257
+ * storage and their assets checked again (checked at import; again in case storage was changed).
258
+ * Grammar and token names are checked again (a token renamed in core stays loud, themes spec
259
+ * §4.1), but not readability: the version passed it at import, and a later change of the built-in
260
+ * tokens must not block an update or a rollback to it. A theme that fails -> 400 `theme.invalid`
261
+ * as at import (themes spec §6: a failing new version blocks the plugin update with the same
262
+ * refusal).
263
+ */
264
+ export declare function readCatalogThemes(storage: StorageService, v: CatalogVersionFiles): Promise<CatalogTheme[]>;
@@ -3,14 +3,14 @@
3
3
  // OrgCatalogService: kept out of org-catalog.service.ts so the installer's module never imports a
4
4
  // module whose provider injects PluginInstaller (an ESM import cycle would run decorators early).
5
5
  import { createHash } from "node:crypto";
6
- import { parseAgentTemplate, parseConnectorTemplateFile, } from "@capybara-hq/plugin-sdk";
6
+ import { parseAgentTemplate, parseConnectorTemplateFile, parseThemeFile, themeIdOf, validateThemeAssets, } from "@capybara-hq/plugin-sdk";
7
7
  import { normalizeToolKeys } from "../agents/definition.js";
8
8
  import { ApiError } from "../errors.js";
9
9
  import { can } from "../orgs/roles.js";
10
10
  import { packagePath } from "../packages/tgz.js";
11
11
  import { checkSkillFiles, SkillArchiveError } from "../skills/package.js";
12
12
  import { readDisplayFiles } from "./catalog-files.js";
13
- import { iconProblem } from "./plugin-package.js";
13
+ import { iconProblem, pluginThemeError, themeApiError } from "./plugin-package.js";
14
14
  import { satisfiesRange } from "./versions.js";
15
15
  /**
16
16
  * A connector template as an org sees it (the install wizard and the upgrade preview's inputs):
@@ -365,3 +365,66 @@ export async function readCatalogAgentTemplates(storage, v) {
365
365
  return { path, template: normalizeToolKeys(parsed.template) };
366
366
  });
367
367
  }
368
+ /** The asset paths a parsed theme references (fonts, logo mark and favicon), without `./`. */
369
+ export function themeAssetPaths(theme) {
370
+ const refs = (theme.fonts ?? []).map((f) => f.file);
371
+ for (const ref of [theme.logo?.mark, theme.logo?.favicon])
372
+ if (ref !== undefined)
373
+ refs.push(ref);
374
+ return [...new Set(refs.map((r) => packagePath(r)))];
375
+ }
376
+ /**
377
+ * The digest of a theme's assets as recorded on a catalog version (`files`): equal digests = the
378
+ * same fonts and logo. With the canonical definition it decides whether a theme changed.
379
+ */
380
+ export function themeAssetsDigest(files, theme) {
381
+ const paths = new Set(themeAssetPaths(theme));
382
+ return skillFilesDigest(files.filter((f) => paths.has(f.path)));
383
+ }
384
+ /**
385
+ * The themes of a catalog version (`manifest.themes`, listed explicitly since import), parsed from
386
+ * storage and their assets checked again (checked at import; again in case storage was changed).
387
+ * Grammar and token names are checked again (a token renamed in core stays loud, themes spec
388
+ * §4.1), but not readability: the version passed it at import, and a later change of the built-in
389
+ * tokens must not block an update or a rollback to it. A theme that fails -> 400 `theme.invalid`
390
+ * as at import (themes spec §6: a failing new version blocks the plugin update with the same
391
+ * refusal).
392
+ */
393
+ export async function readCatalogThemes(storage, v) {
394
+ const refs = v.manifest.themes ?? [];
395
+ if (refs.length === 0)
396
+ return [];
397
+ const read = async (paths) => (await readCatalogFiles(storage, v.object_prefix, v.files.filter((f) => paths.includes(f.path)))).map((f) => [f.path, f.data]);
398
+ const paths = refs.map((r) => packagePath(r.path));
399
+ const texts = new Map(await read(paths));
400
+ const parsed = paths.map((path) => {
401
+ const id = themeIdOf(path);
402
+ const data = texts.get(path);
403
+ if (data === undefined)
404
+ throw new ApiError("internal", `Stored theme ${path} is missing`);
405
+ const r = parseThemeFile(id, new TextDecoder().decode(data), { readability: false });
406
+ if ("errors" in r)
407
+ throw themeApiError(pluginThemeError([{ theme: id, problems: r.errors }]));
408
+ return { id, path, theme: r };
409
+ });
410
+ const files = new Map(await read([...new Set(parsed.flatMap((t) => themeAssetPaths(t.theme)))]));
411
+ const out = [];
412
+ for (const { id, path, theme } of parsed) {
413
+ const { assets, errors } = validateThemeAssets(theme, files);
414
+ if (errors.length > 0)
415
+ throw themeApiError(pluginThemeError([{ theme: id, problems: errors }]));
416
+ out.push({
417
+ id,
418
+ path,
419
+ theme,
420
+ assets: [...assets].map(([p, bytes]) => ({
421
+ path: p,
422
+ size: bytes.byteLength,
423
+ sha256: createHash("sha256").update(bytes).digest("hex"),
424
+ data: bytes,
425
+ })),
426
+ assetsDigest: themeAssetsDigest(v.files, theme),
427
+ });
428
+ }
429
+ return out;
430
+ }
@@ -670,6 +670,7 @@ export function componentCountsOf(m) {
670
670
  return {
671
671
  extension: m?.extension === undefined ? 0 : 1,
672
672
  skills: m?.skills?.length ?? 0,
673
+ themes: m?.themes?.length ?? 0,
673
674
  connectorTemplates: m?.connectorTemplates?.length ?? 0,
674
675
  agentTemplates: m?.agents?.length ?? 0,
675
676
  };
@@ -1,4 +1,5 @@
1
1
  import type { Queue } from "bullmq";
2
+ import { ApiError } from "../errors.js";
2
3
  import { ExtensionAdminOps } from "../extensions/admin-ops.js";
3
4
  import { ExtensionAdmin } from "../extensions/admin-view.js";
4
5
  import { ExtensionLifecycle } from "../extensions/lifecycle.js";
@@ -8,6 +9,7 @@ import { KernelDb } from "../infra/database/kernel-db.js";
8
9
  import { TransactionHost } from "../infra/database/transaction-host.js";
9
10
  import type { KernelLogger } from "../log.js";
10
11
  import { OrgLock } from "../orgs/org-lock.js";
12
+ import { type ThemePreview } from "../themes/theme-usage.js";
11
13
  import { type ActorOpts, type CatalogItemView, CatalogService } from "./catalog.service.js";
12
14
  import { type ComponentKind } from "./component-state.js";
13
15
  import { type PluginConfirmResult, PluginImport } from "./plugin-import.js";
@@ -46,7 +48,9 @@ export interface ScopePreview {
46
48
  }[];
47
49
  /**
48
50
  * The orgs the change would newly offer a skill of the plugin whose own skills already use its
49
- * name (rule 10): the change is refused while there are any.
51
+ * name, or turn a theme of the plugin on in where another theme with its id is on (rule 10,
52
+ * themes spec §6; `kind: "theme"` with the clashing themes in `themes`, `skills` empty): the
53
+ * change is refused while there are any.
50
54
  */
51
55
  conflicts: {
52
56
  orgId: string;
@@ -54,7 +58,16 @@ export interface ScopePreview {
54
58
  name: string;
55
59
  skills: string[];
56
60
  plugins: string[];
61
+ /** Set for a theme id clash; absent for a skill. */
62
+ kind?: "theme";
63
+ /** A theme id clash: the clashing themes on in the org. */
64
+ themes?: string[];
57
65
  }[];
66
+ /**
67
+ * The plugin's themes the change takes away from some orgs (themes spec §6): the orgs among
68
+ * them whose default each is and how many people picked it. Absent when no theme is taken away.
69
+ */
70
+ themes?: ThemePreview[];
58
71
  scopeHash: string;
59
72
  }
60
73
  /** A scope change (`orgs` only with `named`) or a disable. */
@@ -64,6 +77,12 @@ export type ScopeTarget = {
64
77
  } | {
65
78
  disabled: true;
66
79
  };
80
+ /**
81
+ * The refusal of a preview's conflicts: skill ones as `plugin.skill_conflict`, theme ones as
82
+ * `theme.id_taken`; both at once as `plugin.skill_conflict` listing every clash (the skill details
83
+ * first, then the theme ones `{ theme, orgId, themes, plugins }`), so none waits for a retry.
84
+ */
85
+ export declare function conflictsError(conflicts: ScopePreview["conflicts"]): ApiError;
67
86
  /**
68
87
  * The super admin's plugin scope and deployment-level disable (plugin and component management
69
88
  * spec §4.1, §5 rules 2-4 and 8), and the import confirm that enqueues the scope sync of the item it
@@ -234,6 +253,12 @@ export declare class CatalogScopeService {
234
253
  enqueueSync(itemId: string): Promise<void>;
235
254
  /** Enqueues the item's scope sync; a failure only warns (the next change or org sync converges). */
236
255
  private enqueue;
256
+ /**
257
+ * 409 `theme.platform_default` when a live theme of the item is the platform default and `after`
258
+ * says the change leaves it narrower than `all` or gone (themes spec §7). In the caller's
259
+ * transaction, before anything is written.
260
+ */
261
+ private assertKeepsPlatformDefault;
237
262
  /** The live item (row locked with `forUpdate`); missing -> 404, builtin -> `plugin.builtin`. */
238
263
  private item;
239
264
  /** The `named` list of the item, sorted. */
@@ -247,8 +272,9 @@ export declare class CatalogScopeService {
247
272
  * components with their own scope a scope change replaces; the orgs where a skill or agent
248
273
  * template is on now and would be off after (agents merged into `affected`: a component with its
249
274
  * own scope that goes back to following the plugin, or one cut by the disable); and the orgs a
250
- * skill would newly be offered to whose own skills use its name. `onAfter`: the orgs the plugin
251
- * is on in after the change.
275
+ * skill would newly be offered to whose own skills use its name, or a theme would come on in
276
+ * where another theme with its id is on; and per theme the orgs it would go off in
277
+ * (`themesLost`). `onAfter`: the orgs the plugin is on in after the change.
252
278
  */
253
279
  private componentChanges;
254
280
  /**
@@ -26,10 +26,11 @@ import { Transactional } from "../infra/database/transactional.decorator.js";
26
26
  import { KERNEL_LOGGER } from "../infra/tokens.js";
27
27
  import { OrgLock } from "../orgs/org-lock.js";
28
28
  import { MAINTENANCE_QUEUE_NAME } from "../runtime/maintenance-queue.js";
29
+ import { assertNotPlatformDefault, themesPreview, } from "../themes/theme-usage.js";
29
30
  import { CatalogService } from "./catalog.service.js";
30
31
  import { assertOrgsExist, markVersionApplied } from "./catalog-items.js";
31
32
  import { nameConflictError } from "./component-scope.service.js";
32
- import { agentsUsing, COMPONENT_KINDS, componentOffered, componentOn, listedIn, loadComponents, loadScopeState, pluginFactsOf, skillNameClashes, switchOf, } from "./component-state.js";
33
+ import { agentsUsing, COMPONENT_KINDS, componentOffered, componentOn, listedIn, loadComponents, loadScopeState, pluginFactsOf, skillNameClashes, switchOf, themeIdClashes, } from "./component-state.js";
33
34
  import { ownedPluginCleanupJob } from "./job-ids.js";
34
35
  import { assertNotAwaiting, assertOwnerListed, assertShareable, assertShareableTarget, itemRemovedError, newlySharedWith, ownerOf, ownersOf, PLATFORM_OWNER, packageOwner, pendingDigest, releaseExtensionClaim, } from "./ownership.js";
35
36
  import { PluginImport } from "./plugin-import.js";
@@ -63,9 +64,30 @@ function scopeHash(target, p) {
63
64
  needsConfig: [...p.needsConfig].sort(),
64
65
  replaced: [...p.replaced].sort((a, b) => a.id.localeCompare(b.id)),
65
66
  conflicts: [...p.conflicts].sort((a, b) => a.orgId.localeCompare(b.orgId)),
67
+ // only when present: a preview without themes keeps the hash it had before themes
68
+ ...(p.themes !== undefined && { themes: p.themes }),
66
69
  }))
67
70
  .digest("hex");
68
71
  }
72
+ /**
73
+ * The refusal of a preview's conflicts: skill ones as `plugin.skill_conflict`, theme ones as
74
+ * `theme.id_taken`; both at once as `plugin.skill_conflict` listing every clash (the skill details
75
+ * first, then the theme ones `{ theme, orgId, themes, plugins }`), so none waits for a retry.
76
+ */
77
+ export function conflictsError(conflicts) {
78
+ const skills = conflicts.filter((c) => c.kind !== "theme");
79
+ const themes = conflicts.filter((c) => c.kind === "theme").map(({ kind: _k, ...c }) => c);
80
+ if (themes.length === 0)
81
+ return nameConflictError(skills);
82
+ const t = nameConflictError(themes, "theme");
83
+ if (skills.length === 0)
84
+ return t;
85
+ const s = nameConflictError(skills);
86
+ return new ApiError(s.code, `${s.message}\n${t.message}`, [
87
+ ...(s.details ?? []),
88
+ ...(t.details ?? []),
89
+ ]);
90
+ }
69
91
  /**
70
92
  * The super admin's plugin scope and deployment-level disable (plugin and component management
71
93
  * spec §4.1, §5 rules 2-4 and 8), and the import confirm that enqueues the scope sync of the item it
@@ -676,11 +698,14 @@ let CatalogScopeService = class CatalogScopeService {
676
698
  // The owner stays listed (P3-17); widening goes through the share gate (P3-16)
677
699
  assertOwnerListed(item, t);
678
700
  assertShareableTarget(item, { scope: item.scope, orgs: from }, t);
701
+ // A scope change sends every theme back to following the plugin (rule 2): each is `all` after
702
+ // only when the plugin is (themes spec §7)
703
+ await this.assertKeepsPlatformDefault(item.id, (c) => c.disabled || item.disabled ? "gone" : t.scope === "all" ? "all" : "narrower");
679
704
  const p = await this.compute(item, t);
680
705
  if (p.scopeHash !== hash)
681
706
  throw staleError();
682
707
  if (p.conflicts.length > 0)
683
- throw nameConflictError(p.conflicts);
708
+ throw conflictsError(p.conflicts);
684
709
  if (item.scope === t.scope && from.join() === t.orgs.join() && p.replaced.length === 0)
685
710
  return;
686
711
  await this.followAgain(actor, itemId, p.replaced, opts);
@@ -723,13 +748,15 @@ let CatalogScopeService = class CatalogScopeService {
723
748
  const item = await this.item(itemId, true);
724
749
  if (item.disabled === disabled)
725
750
  return;
751
+ if (disabled)
752
+ await this.assertKeepsPlatformDefault(item.id, () => "gone");
726
753
  const p = await this.compute(item, disabled
727
754
  ? await this.targetOf(item, { disabled: true })
728
755
  : { scope: item.scope, orgs: await this.listed(itemId), disabled, scopeChange: false });
729
756
  if (disabled && p.scopeHash !== hash)
730
757
  throw staleError();
731
758
  if (p.conflicts.length > 0)
732
- throw nameConflictError(p.conflicts);
759
+ throw conflictsError(p.conflicts);
733
760
  await this.db
734
761
  .updateTable("catalog_items")
735
762
  .set({
@@ -780,6 +807,7 @@ let CatalogScopeService = class CatalogScopeService {
780
807
  "platform_skills",
781
808
  "platform_agent_templates",
782
809
  "platform_connector_templates",
810
+ "platform_themes",
783
811
  ]) {
784
812
  await this.db
785
813
  .updateTable(table)
@@ -830,6 +858,15 @@ let CatalogScopeService = class CatalogScopeService {
830
858
  this.log.warn("enqueueing the plugin scope sync failed", { itemId, err });
831
859
  }
832
860
  }
861
+ /**
862
+ * 409 `theme.platform_default` when a live theme of the item is the platform default and `after`
863
+ * says the change leaves it narrower than `all` or gone (themes spec §7). In the caller's
864
+ * transaction, before anything is written.
865
+ */
866
+ async assertKeepsPlatformDefault(itemId, after) {
867
+ const themes = await loadComponents(this.db, "theme", { itemId });
868
+ await assertNotPlatformDefault(this.db, themes.map((c) => c.id), (id) => after(themes.find((c) => c.id === id) ?? { disabled: false }));
869
+ }
833
870
  /** The live item (row locked with `forUpdate`); missing -> 404, builtin -> `plugin.builtin`. */
834
871
  async item(itemId, forUpdate) {
835
872
  if (!UUID_RE.test(itemId))
@@ -954,8 +991,15 @@ let CatalogScopeService = class CatalogScopeService {
954
991
  else
955
992
  onAfter.add(org.id);
956
993
  }
957
- const { replaced, conflicts } = await this.componentChanges(item, target, orgs, onAfter, affected);
958
- const p = { affected, needsConfig, replaced, conflicts };
994
+ const { replaced, conflicts, themesLost } = await this.componentChanges(item, target, orgs, onAfter, affected);
995
+ const themes = await themesPreview(db, themesLost);
996
+ const p = {
997
+ affected,
998
+ needsConfig,
999
+ replaced,
1000
+ conflicts,
1001
+ ...(themes !== undefined && { themes }),
1002
+ };
959
1003
  return { ...p, scopeHash: scopeHash(target, p) };
960
1004
  }
961
1005
  /**
@@ -963,13 +1007,15 @@ let CatalogScopeService = class CatalogScopeService {
963
1007
  * components with their own scope a scope change replaces; the orgs where a skill or agent
964
1008
  * template is on now and would be off after (agents merged into `affected`: a component with its
965
1009
  * own scope that goes back to following the plugin, or one cut by the disable); and the orgs a
966
- * skill would newly be offered to whose own skills use its name. `onAfter`: the orgs the plugin
967
- * is on in after the change.
1010
+ * skill would newly be offered to whose own skills use its name, or a theme would come on in
1011
+ * where another theme with its id is on; and per theme the orgs it would go off in
1012
+ * (`themesLost`). `onAfter`: the orgs the plugin is on in after the change.
968
1013
  */
969
1014
  async componentChanges(item, target, orgs, onAfter, affected) {
970
1015
  const db = this.db;
971
1016
  const replaced = [];
972
1017
  const conflicts = [];
1018
+ const themesLost = new Map();
973
1019
  const lose = new Map();
974
1020
  for (const kind of COMPONENT_KINDS) {
975
1021
  const comps = await loadComponents(db, kind, { itemId: item.id });
@@ -997,7 +1043,31 @@ let CatalogScopeService = class CatalogScopeService {
997
1043
  };
998
1044
  const listed = listedIn(st, c.id, org.id);
999
1045
  const s = switchOf(st, c.id, org.id);
1000
- if (componentOn(c, pNow, listed, s) && !componentOn(cAfter, pAfter, listed, s)) {
1046
+ const onNow = componentOn(c, pNow, listed, s);
1047
+ const onAfterChange = componentOn(cAfter, pAfter, listed, s);
1048
+ if (kind === "theme") {
1049
+ if (onNow && !onAfterChange) {
1050
+ const set = themesLost.get(c.id) ?? new Set();
1051
+ set.add(org.id);
1052
+ themesLost.set(c.id, set);
1053
+ }
1054
+ if (onAfterChange && !onNow) {
1055
+ const clash = await themeIdClashes(db, org.id, c, c.name);
1056
+ if (clash.length > 0) {
1057
+ conflicts.push({
1058
+ orgId: org.id,
1059
+ orgName: org.name,
1060
+ name: c.name,
1061
+ skills: [],
1062
+ themes: clash.map((x) => x.id),
1063
+ plugins: clash.map((x) => x.plugin ?? "platform"),
1064
+ kind: "theme",
1065
+ });
1066
+ }
1067
+ }
1068
+ continue;
1069
+ }
1070
+ if (onNow && !onAfterChange) {
1001
1071
  for (const a of await agentsUsing(db, org.id, kind, [c.id])) {
1002
1072
  const m = lose.get(org.id) ?? new Map();
1003
1073
  m.set(a.id, a.name);
@@ -1031,7 +1101,7 @@ let CatalogScopeService = class CatalogScopeService {
1031
1101
  e.agents.push({ id, name });
1032
1102
  }
1033
1103
  }
1034
- return { replaced, conflicts };
1104
+ return { replaced, conflicts, themesLost };
1035
1105
  }
1036
1106
  /**
1037
1107
  * Sends the components of the item with their own scope back to following it (a plugin scope
@@ -1044,7 +1114,9 @@ let CatalogScopeService = class CatalogScopeService {
1044
1114
  ? "platform_skills"
1045
1115
  : r.kind === "agent_template"
1046
1116
  ? "platform_agent_templates"
1047
- : "platform_connector_templates";
1117
+ : r.kind === "theme"
1118
+ ? "platform_themes"
1119
+ : "platform_connector_templates";
1048
1120
  await this.db
1049
1121
  .updateTable(table)
1050
1122
  .set({
@@ -111,6 +111,7 @@ export interface ComponentCounts {
111
111
  skills: number;
112
112
  connectorTemplates: number;
113
113
  agentTemplates: number;
114
+ themes: number;
114
115
  }
115
116
  /**
116
117
  * A git source as recorded (spec §5.1: the actual host, repo and commit, and whether it follows a
@@ -329,8 +330,8 @@ export declare class CatalogService {
329
330
  /**
330
331
  * Deletes the item (soft delete, audit `catalog.removed`; requests still pending are cancelled in
331
332
  * the same transaction): versions and stored files are kept (installed components still refer to
332
- * them), organizations no longer see it; the same name can later be imported again as a new
333
- * item. Deleted / missing -> `catalog.item_not_found`. A platform plugin whose extension is
333
+ * them), its themes are soft-deleted (nothing refers to their rows), organizations no longer see
334
+ * it; the same name can later be imported again as a new item. Deleted / missing -> `catalog.item_not_found`. A platform plugin whose extension is
334
335
  * still enabled on the deployment -> 409 `catalog.extension_enabled` (details
335
336
  * `[{ extension, displayName }]`): disable it on the Extensions page first, as an uninstall
336
337
  * requires. A disabled extension keeps its install record (the super admin uninstalls it on the
@@ -353,9 +354,10 @@ export declare class CatalogService {
353
354
  * The soft delete of {@link remove} in the caller's transaction, without its super admin check
354
355
  * (pre-flight F14: the super admin's remove and an owner's delete or an org deletion,
355
356
  * `OwnedItems.transferOrRemove`, share it): `deleted_at` set, a pending version cleared, the
356
- * extension name claim of a first upload still waiting released (ruling P3-37, F10), pending
357
- * requests cancelled, audit `catalog.removed` (`actor` null: the kernel). Returns the removed
358
- * item's name, or null when it is gone already.
357
+ * extension name claim of a first upload still waiting released (ruling P3-37, F10), its themes
358
+ * soft-deleted (refused with 409 `theme.platform_default` while one is the platform default),
359
+ * pending requests cancelled, audit `catalog.removed` (`actor` null: the kernel). Returns the
360
+ * removed item's name, or null when it is gone already.
359
361
  */
360
362
  removeIn(actor: string | null, itemId: string, opts?: ActorOpts & {
361
363
  reason?: string;
@@ -21,6 +21,7 @@ import { KernelDb } from "../infra/database/kernel-db.js";
21
21
  import { TransactionHost } from "../infra/database/transaction-host.js";
22
22
  import { Transactional } from "../infra/database/transactional.decorator.js";
23
23
  import { sameRepository } from "../packages/provenance.js";
24
+ import { assertNotPlatformDefault } from "../themes/theme-usage.js";
24
25
  import { countInstalls, itemConfirmations, itemScopeFacts, itemView, marketplaceLatest, versionUsable, } from "./catalog-items.js";
25
26
  import { expandGitUrl, GitSourceError, gitErrorDetails, normalGitPath, readGitTreeAndOids, resolveGitCommit, } from "./git-source.js";
26
27
  import { fetchMarketplaceArchive, marketplaceNetPolicy, marketplaceUrlProblem, } from "./marketplace-fetch.js";
@@ -463,8 +464,8 @@ let CatalogService = class CatalogService {
463
464
  /**
464
465
  * Deletes the item (soft delete, audit `catalog.removed`; requests still pending are cancelled in
465
466
  * the same transaction): versions and stored files are kept (installed components still refer to
466
- * them), organizations no longer see it; the same name can later be imported again as a new
467
- * item. Deleted / missing -> `catalog.item_not_found`. A platform plugin whose extension is
467
+ * them), its themes are soft-deleted (nothing refers to their rows), organizations no longer see
468
+ * it; the same name can later be imported again as a new item. Deleted / missing -> `catalog.item_not_found`. A platform plugin whose extension is
468
469
  * still enabled on the deployment -> 409 `catalog.extension_enabled` (details
469
470
  * `[{ extension, displayName }]`): disable it on the Extensions page first, as an uninstall
470
471
  * requires. A disabled extension keeps its install record (the super admin uninstalls it on the
@@ -522,13 +523,25 @@ let CatalogService = class CatalogService {
522
523
  * The soft delete of {@link remove} in the caller's transaction, without its super admin check
523
524
  * (pre-flight F14: the super admin's remove and an owner's delete or an org deletion,
524
525
  * `OwnedItems.transferOrRemove`, share it): `deleted_at` set, a pending version cleared, the
525
- * extension name claim of a first upload still waiting released (ruling P3-37, F10), pending
526
- * requests cancelled, audit `catalog.removed` (`actor` null: the kernel). Returns the removed
527
- * item's name, or null when it is gone already.
526
+ * extension name claim of a first upload still waiting released (ruling P3-37, F10), its themes
527
+ * soft-deleted (refused with 409 `theme.platform_default` while one is the platform default),
528
+ * pending requests cancelled, audit `catalog.removed` (`actor` null: the kernel). Returns the
529
+ * removed item's name, or null when it is gone already.
528
530
  */
529
531
  async removeIn(actor, itemId, opts = {}) {
530
532
  const t = this.db;
531
533
  const now = sql `now()`;
534
+ // Its themes go with it: refused while one is the platform default (themes spec §7), before
535
+ // anything is written
536
+ // (the theme rows FOR UPDATE first: the theme lock order is item, theme row, `uiTheme`)
537
+ const themes = await t
538
+ .selectFrom("platform_themes")
539
+ .select("id")
540
+ .where("catalog_item_id", "=", itemId)
541
+ .where("deleted_at", "is", null)
542
+ .forUpdate()
543
+ .execute();
544
+ await assertNotPlatformDefault(t, themes.map((r) => r.id), () => "gone");
532
545
  const row = await t
533
546
  .updateTable("catalog_items")
534
547
  .set({
@@ -551,6 +564,14 @@ let CatalogService = class CatalogService {
551
564
  await this.installLock.lock(row.name);
552
565
  await releaseExtensionClaim(t, row.name, row.package_name);
553
566
  }
567
+ // Its themes go with it (themes spec §6): nothing refers to a theme row (a theme picked by an
568
+ // org or a person is an id that falls back once no live theme has it)
569
+ await t
570
+ .updateTable("platform_themes")
571
+ .set({ deleted_at: now, updated_at: now })
572
+ .where("catalog_item_id", "=", itemId)
573
+ .where("deleted_at", "is", null)
574
+ .execute();
554
575
  // Pending requests for a deleted item are pointless: cancel them in the same transaction
555
576
  await cancelPendingRequests(t, { itemId }, actor, "item_removed", opts);
556
577
  await audit(t, {
@@ -836,6 +836,9 @@ export function translateClaudePlugin(files, o) {
836
836
  // the raw Claude agents/*.md the tree still holds and parse them as Capybara templates
837
837
  // (batch 12 review I1); normalizedManifest drops an empty list, so digests are unchanged
838
838
  agents: agents.agents.map((a) => ({ path: `./${a.path}` })),
839
+ // always empty, for the same reason: a Claude Code plugin's themes/*.json are not Capybara
840
+ // themes (themes spec §4); normalizedManifest drops the empty list
841
+ themes: [],
839
842
  };
840
843
  const checked = parsePluginManifest(manifest);
841
844
  if ("errors" in checked) {